Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ed5018a2f | |||
| aacd63b85b | |||
| 22de5009ff | |||
| e94038481e | |||
| b441f32949 | |||
| cb19d7e5d2 | |||
| 0cb5e41faf | |||
| 1c5521cf2e | |||
| c3f83d3922 | |||
| 0c2f49876d | |||
| 9c573f1e3c | |||
| d72a255294 | |||
| b08482c6aa | |||
| 763759189f |
@@ -22,7 +22,8 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
||||
"timeout": 5
|
||||
"timeout": 5,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,7 +35,8 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
||||
"timeout": 5
|
||||
"timeout": 5,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,9 +23,8 @@ make while you work here as Codex.
|
||||
- `scripts/` for hook/install/import/cleanup utilities.
|
||||
|
||||
## Validation expectations
|
||||
- Backend changes: run `npm run test:server` when possible.
|
||||
- Frontend changes: run `npm run test:client` when possible.
|
||||
- Full suites take 5–6 minutes, so while iterating run only what the change touches: `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Run the full suite once before committing, and always when the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib) — the per-screen snapshots catch that.
|
||||
- Run only the tests the change touches, never a full suite by default (full suites take 5–6 minutes): `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Docs-only changes run no tests.
|
||||
- Run a full suite (`npm run test:server` / `npm run test:client`) only when genuinely needed: the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib), a shared server core (`server/db.js`, `server/index.js`, auth/scope, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks. The pre-commit hook already picks related vs. full tests per commit, so do not add an extra full run before committing.
|
||||
- If any check is skipped, report it explicitly.
|
||||
|
||||
## Safety expectations
|
||||
|
||||
+16
-13
@@ -375,7 +375,7 @@ graph TD
|
||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
|
||||
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree`, `/:id/git/download` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/upload` (create-only, `lane-file-transfer.js`) and `POST /:id/git/:action` are same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
|
||||
@@ -385,7 +385,8 @@ graph TD
|
||||
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
|
||||
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
||||
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
||||
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
||||
| `lib/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
|
||||
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
||||
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
|
||||
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
||||
@@ -757,6 +758,7 @@ erDiagram
|
||||
TEXT metadata "JSON blob"
|
||||
TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
|
||||
TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
|
||||
TEXT pinned_at "ISO 8601 or NULL — pinned to the top of its workspace's sidebar list"
|
||||
TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'"
|
||||
}
|
||||
|
||||
@@ -2354,7 +2356,7 @@ graph TD
|
||||
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
||||
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||
PANEL["SagiPanel.tsx<br/>status + quick actions + Ask box"]
|
||||
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
|
||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||
end
|
||||
|
||||
@@ -2401,16 +2403,17 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
|
||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`, which also returns the `subject` session a pulse is about — name, cwd, lane) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. An error alerts on the transition INTO `error` only, and **Clear alerts** (`clearErrors`) moves the errored sessions into `dismissed`: their later updates, agent errors and failure events stay silent until the session goes active again or ends. `attentionOf` lists errored then waiting sessions by name; `MOMENT_OF_PULSE` maps each pulse to a pet animation. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
|
||||
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
|
||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
|
||||
| **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: `Sagi.tsx` calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`quips.ts`** | Pure mood → phrase pools. |
|
||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
||||
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
|
||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. |
|
||||
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. |
|
||||
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
|
||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
|
||||
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
|
||||
|
||||
### Touchpoints Outside the Folder
|
||||
|
||||
@@ -2422,7 +2425,7 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
|
||||
| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). |
|
||||
| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. |
|
||||
| `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. |
|
||||
| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). |
|
||||
| `client/src/i18n/locales/{en,vi}/settings.json` | Every `sagi.*` string: the Settings toggle, the panel, the bubbles' quips, coalesced counts, greetings and Ask answers. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
|
||||
+5
-1
@@ -2,7 +2,8 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount, and
|
||||
src/lib/fontSize.ts). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
@@ -17,6 +18,9 @@
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
// Text size (src/lib/fontSize.ts, UI_FONT_SIZES): the root font size.
|
||||
var f = Number(localStorage.getItem("agenthub.ui.fontSize"));
|
||||
if (f === 14 || f === 17 || f === 18) r.style.fontSize = f + "px";
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
Generated
-42
@@ -1204,9 +1204,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1362,9 +1359,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1379,9 +1373,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1396,9 +1387,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1413,9 +1401,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1430,9 +1415,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1447,9 +1429,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1464,9 +1443,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1481,9 +1457,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1498,9 +1471,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1515,9 +1485,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1532,9 +1499,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1549,9 +1513,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1566,9 +1527,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -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";
|
||||
@@ -87,7 +88,9 @@ const SessionDetail = lazy(() =>
|
||||
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 Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +110,11 @@ function Page({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A page only a shared server needs (the File Explorer): local mode goes home. */
|
||||
function ServerOnly({ children }: { children: ReactNode }) {
|
||||
return usePermissions().local ? <Navigate to="/" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
/** Keyed by the id so moving to another session starts from fresh state. */
|
||||
function SessionDetailRoute() {
|
||||
const { id } = useParams();
|
||||
@@ -210,6 +218,24 @@ export default function App() {
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="artifacts"
|
||||
element={
|
||||
<Page>
|
||||
<Artifacts />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
<ServerOnly>
|
||||
<Page>
|
||||
<FilesPage />
|
||||
</Page>
|
||||
</ServerOnly>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
|
||||
* previous user's data. `useAuth()` gives the sidebar the user, `logout`, and
|
||||
* `updateUser` (Settings → Account's saved display name shows at once);
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
@@ -16,6 +17,7 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -30,9 +32,17 @@ import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||
type AuthValue = {
|
||||
state: AuthState;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (patch: Partial<AuthUser>) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
|
||||
const AuthContext = createContext<AuthValue>({
|
||||
state: { mode: "local" },
|
||||
logout: async () => {},
|
||||
updateUser: () => {},
|
||||
});
|
||||
|
||||
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -53,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) =>
|
||||
@@ -134,6 +148,15 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (account) void hydrate();
|
||||
}, [account]);
|
||||
|
||||
// Stable, so a page may list it as an effect dependency.
|
||||
const updateUser = useCallback(
|
||||
(patch: Partial<AuthUser>) =>
|
||||
setState((s) =>
|
||||
s && s.mode === "server" && s.user ? { ...s, user: { ...s.user, ...patch } } : s
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
@@ -142,7 +165,9 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type Ref } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -23,15 +23,12 @@ export function PathPickButton({
|
||||
start,
|
||||
onPick,
|
||||
showLabel = false,
|
||||
buttonRef,
|
||||
}: {
|
||||
kind?: "dir" | "file";
|
||||
/** The field's current value — the picker opens there. */
|
||||
start?: string;
|
||||
onPick: (path: string) => void;
|
||||
showLabel?: boolean;
|
||||
/** Lets a field beside it open the same picker (a click on an empty path). */
|
||||
buttonRef?: Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -55,7 +52,6 @@ export function PathPickButton({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={busy}
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window. A question its Ask box cannot answer
|
||||
* locally opens here instead of leaving the page: the first message starts a
|
||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
||||
* forgets it (the session stays in the workspace), **Open in workspace**
|
||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||
* is gone, the next message starts a new one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { TerminalView } from "../run/TerminalView";
|
||||
import { PaneMenuCard } from "../run/PaneChat";
|
||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||
|
||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
const CHAT_KEY = "agenthub.pet.chat";
|
||||
const POLL_MS = 1500;
|
||||
const MENU_POLL_MS = 700;
|
||||
// A follow-up waits this long for the off-screen pane to attach.
|
||||
const SEND_TRIES = 20;
|
||||
const SEND_RETRY_MS = 250;
|
||||
|
||||
/** The session the chat is talking to. */
|
||||
export interface PetChatRef {
|
||||
laneId: number;
|
||||
runId: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export function readPetChat(): PetChatRef | null {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||
if (
|
||||
v &&
|
||||
typeof v.laneId === "number" &&
|
||||
typeof v.runId === "string" &&
|
||||
typeof v.tool === "string"
|
||||
)
|
||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||
} catch {
|
||||
/* unreadable: no chat */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePetChat(ref: PetChatRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||
else localStorage.removeItem(CHAT_KEY);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||
export function petChatTool(): string | null {
|
||||
const tool = readDefaultTool() ?? "claude";
|
||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||
}
|
||||
|
||||
type Line =
|
||||
| { key: string; role: "user" | "assistant"; text: string }
|
||||
| { key: string; role: "tools"; names: string[] };
|
||||
|
||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||
* `<system-reminder>`…) are not something the user said. */
|
||||
const humanText = (m: TranscriptMessage) =>
|
||||
m.content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!.trim())
|
||||
.filter((t) => t && !t.startsWith("<"))
|
||||
.join("\n\n");
|
||||
|
||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||
* each run of tool calls between them folded into one chip. */
|
||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||
const out: Line[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
const sender = m.sender ?? m.type;
|
||||
if (sender === "user") {
|
||||
const text = humanText(m);
|
||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||
return;
|
||||
}
|
||||
if (m.type !== "assistant") return;
|
||||
m.content.forEach((c, j) => {
|
||||
if (c.type === "text" && c.text?.trim()) {
|
||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||
} else if (c.type === "tool_use" && c.name) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "tools") last.names.push(c.name);
|
||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
const sender = m.sender ?? m.type;
|
||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||
if (sender === "user" && humanText(m)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** "Read ×2 · Bash" */
|
||||
function toolSummary(names: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||
}
|
||||
|
||||
interface PetChatProps {
|
||||
/** The pet's shown name, for the header and greeting. */
|
||||
name: string;
|
||||
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
||||
initialPrompt?: string | null;
|
||||
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
||||
onPromptTaken?: () => void;
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
/** Opens the chat's pane in its workspace. */
|
||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||
/** The thinking mood while a reply is being written. */
|
||||
onBusy?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
initialPrompt,
|
||||
onPromptTaken,
|
||||
onBack,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
}: PetChatProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// Sent but not in the transcript yet.
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remember = (ref: PetChatRef | null) => {
|
||||
writePetChat(ref);
|
||||
setChat(ref);
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
let live = true;
|
||||
const check = () =>
|
||||
api.run
|
||||
.get(chat.runId)
|
||||
.then((h) => {
|
||||
if (!live) return;
|
||||
if (h.status !== "running") remember(null);
|
||||
else if (h.sessionId) setSessionId(h.sessionId);
|
||||
})
|
||||
.catch(() => live && remember(null));
|
||||
void check();
|
||||
// Codex learns its session only after a while; Claude's is known at start.
|
||||
const timer = window.setInterval(() => {
|
||||
if (!sessionId) void check();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat?.runId, sessionId]);
|
||||
|
||||
// The conversation, polled while the window is open.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.transcript(sessionId, { limit: 60 })
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages((prev) =>
|
||||
prev.length === r.messages.length &&
|
||||
JSON.stringify(prev[prev.length - 1]) ===
|
||||
JSON.stringify(r.messages[r.messages.length - 1])
|
||||
? prev
|
||||
: r.messages
|
||||
);
|
||||
})
|
||||
.catch(() => undefined); // not started yet: the next poll asks again
|
||||
void load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
const read = () =>
|
||||
setMenu((prev) => {
|
||||
const next = readRunMenu(chat.runId);
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = chatLines(messages);
|
||||
// The pending line shows until the transcript has it.
|
||||
useEffect(() => {
|
||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||
setPending(null);
|
||||
}, [lines, pending]);
|
||||
const busy = starting || !!pending || isBusy(messages);
|
||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||
|
||||
// Keep the newest in sight.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length, pending, busy, menu]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const prompt = text.trim();
|
||||
if (!prompt || starting) return;
|
||||
setError(null);
|
||||
setPending(prompt);
|
||||
if (chat) {
|
||||
// Typed into the attached pane; it may still be attaching.
|
||||
for (let i = 0; i < SEND_TRIES; i++) {
|
||||
if (submitToRun(chat.runId, prompt)) return;
|
||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||
}
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(t("sagi.chat.notSent"));
|
||||
return;
|
||||
}
|
||||
const tool = petChatTool() ?? "claude";
|
||||
setStarting(true);
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(
|
||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t]
|
||||
);
|
||||
|
||||
// The Ask box's question, sent once.
|
||||
const taken = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialPrompt || taken.current) return;
|
||||
taken.current = true;
|
||||
onPromptTaken?.();
|
||||
void send(initialPrompt);
|
||||
}, [initialPrompt, onPromptTaken, send]);
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
const text = draft;
|
||||
setDraft("");
|
||||
void send(text);
|
||||
};
|
||||
|
||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||
const empty = lines.length === 0 && !pending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.chat.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
||||
<ArrowLeft size={16} />
|
||||
</IconButton>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
busy
|
||||
? "animate-pulse bg-status-warning"
|
||||
: chat
|
||||
? "bg-status-success"
|
||||
: "bg-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => remember(null)}
|
||||
disabled={!chat || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="text-3xl" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{t("sagi.chat.emptyTitle", { name })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((l) =>
|
||||
l.role === "tools" ? (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||
<span className="truncate">{toolSummary(l.names)}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : l.role === "user" ? (
|
||||
<div key={l.key} className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||
{l.text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||
<MarkdownContent text={l.text} dense prose />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{pending && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||
{pending}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{busy && !menu && (
|
||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{starting && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||
</p>
|
||||
)}
|
||||
{chat && !sessionId && !starting && (
|
||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* prompt, or the question the CLI asks */}
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{menu && chat ? (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
<PaneMenuCard
|
||||
menu={menu}
|
||||
onChoose={(i) => {
|
||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onAnswer={(text) => {
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.chat.placeholder", { name })}
|
||||
aria-label={t("sagi.chat.placeholder", { name })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc leaves the chat for the panel, not the whole mascot.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onBack();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || starting}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||
{chat && (
|
||||
<div className="pet-chat-term" aria-hidden>
|
||||
<TerminalView
|
||||
runId={chat.runId}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={false}
|
||||
// The pane ended: the conversation stays on screen, the next
|
||||
// message starts a new session.
|
||||
onExit={() => {
|
||||
writePetChat(null);
|
||||
setChat(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||
* Codex's own pet; a `moment` (a new session waves, a finish jumps, an error
|
||||
* fails, a wait waits, a new subagent reviews — `useSagiBrain`) plays its row
|
||||
* over the mood's, under the action; a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
@@ -18,7 +20,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const COLS = 8;
|
||||
@@ -58,12 +60,18 @@ const ROW: Record<Mood, number> = {
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** A moment's row: wave, jump, failed, waiting, review. It outranks the mood,
|
||||
* and the user's own action outranks it. */
|
||||
const MOMENT_ROW: Record<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 };
|
||||
/** Loops of a row before the sprite rests on its first frame. */
|
||||
const SPRITE_LOOPS = 4;
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
moment,
|
||||
gaze,
|
||||
size,
|
||||
reducedMotion,
|
||||
@@ -73,6 +81,7 @@ export function PetSprite({
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
moment?: PetMoment | null;
|
||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||
gaze?: number | null;
|
||||
size: number;
|
||||
@@ -80,8 +89,8 @@ export function PetSprite({
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && mood === "sleeping";
|
||||
const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && !moment && mood === "sleeping";
|
||||
// The cell on show, tagged with the row it was played for: a cell left over
|
||||
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||
@@ -95,13 +104,17 @@ export function PetSprite({
|
||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||
let i = 0;
|
||||
let loops = 0;
|
||||
let timer = 0;
|
||||
// `i` always indexes `list`: it wraps at its end.
|
||||
// `i` always indexes `list`: it wraps at its end. A few loops, then the
|
||||
// first frame rests — a sprite that never stops re-paints the page several
|
||||
// times a second for as long as it is open; a new mood or action plays again.
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(() => {
|
||||
if (++i === list.length) {
|
||||
i = 0;
|
||||
if (MOMENTS.has(row)) list = play(0);
|
||||
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
|
||||
}
|
||||
setCell(list[i]!);
|
||||
tick();
|
||||
@@ -132,6 +145,7 @@ export function PetSprite({
|
||||
src={url}
|
||||
alt=""
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
className="sagi-pet-image"
|
||||
width={size}
|
||||
height={size}
|
||||
@@ -141,7 +155,8 @@ export function PetSprite({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
|
||||
const pose =
|
||||
gaze != null && rows > GAZE_ROW && !action && !moment && mood === "idle" ? gaze : null;
|
||||
const shown =
|
||||
pose != null
|
||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||
|
||||
@@ -88,8 +88,19 @@ import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { petName, sagiPrefs } from "./prefs";
|
||||
import type { SagiSubject } from "./brain";
|
||||
import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { PetChat, petChatTool } from "./PetChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
const STROKE_TURNS = 4;
|
||||
const STROKE_MS = 1200;
|
||||
const STROKE_COOLDOWN_MS = 1500;
|
||||
import "./sagi.css";
|
||||
|
||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||
@@ -138,15 +149,21 @@ export function usePrefersReducedMotion(): boolean {
|
||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||
* into view instead of being cropped.
|
||||
*/
|
||||
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
|
||||
/**
|
||||
* `front`: above the mascot itself — the pet menu, which may overlap it.
|
||||
* `beside`: at the mascot's side instead of above it — the chat window, on the
|
||||
* screen side away from the edge it docks to, its bottom level with the pet's.
|
||||
*/
|
||||
function SagiFlyout({
|
||||
anchor,
|
||||
children,
|
||||
front = false,
|
||||
beside = false,
|
||||
}: {
|
||||
anchor: Anchor;
|
||||
children: ReactNode;
|
||||
front?: boolean;
|
||||
beside?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
@@ -159,6 +176,18 @@ function SagiFlyout({
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
if (beside) {
|
||||
let left =
|
||||
anchor.side === "left"
|
||||
? anchor.left + anchor.size + FLYOUT_GAP
|
||||
: anchor.left - w - FLYOUT_GAP;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
let top = anchor.top + anchor.size - h;
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
||||
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
@@ -171,7 +200,7 @@ function SagiFlyout({
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
place();
|
||||
@@ -209,8 +238,13 @@ export function Sagi() {
|
||||
function SagiBody() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
const [open, setOpen] = useState(false);
|
||||
// The chat window (PetChat.tsx), and the Ask question it should send.
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
@@ -226,11 +260,32 @@ function SagiBody() {
|
||||
() =>
|
||||
sagiPrefs.subscribe(() => {
|
||||
setPet(sagiPrefs.getPet());
|
||||
setName(petName(sagiPrefs.getPet()));
|
||||
setSize(sagiPrefs.getSize());
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// A pet worn before names rode along with the choice: look its name up once.
|
||||
const petId = pet.id;
|
||||
const unnamed = "kind" in pet && !pet.name;
|
||||
useEffect(() => {
|
||||
if (!unnamed) return;
|
||||
let live = true;
|
||||
api.pets
|
||||
.list()
|
||||
.then((list) => {
|
||||
const found = list.pets.find((p) => p.id === petId);
|
||||
const cur = sagiPrefs.getPet();
|
||||
if (live && found && cur.id === petId && "kind" in cur)
|
||||
sagiPrefs.setPet({ ...cur, name: found.displayName });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [petId, unnamed]);
|
||||
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||
useEffect(() => {
|
||||
@@ -241,6 +296,7 @@ function SagiBody() {
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -281,27 +337,53 @@ function SagiBody() {
|
||||
[navigate]
|
||||
);
|
||||
|
||||
// A session a bubble or the panel names: its live pane when a dashboard run
|
||||
// holds it, else its workspace's replay, else its analysis page. Opening
|
||||
// never resumes anything.
|
||||
const openSession = useCallback(
|
||||
(s: SagiSubject) => {
|
||||
const go = (runs?: Parameters<typeof workspaceHref>[1]) => {
|
||||
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||
brain.dismissBubble();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
};
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => go(r.items))
|
||||
.catch(() => go());
|
||||
},
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
const onAsk = useCallback(
|
||||
(query: string): string | null => {
|
||||
const result = matchIntent(query, brain.status);
|
||||
if (result.kind === "answer") return result.text;
|
||||
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
||||
// fresh claude session in it carrying the user's prompt as its first
|
||||
// message, then open that workspace's session view on the new pane.
|
||||
// Fire-and-forget — the panel already clears its composer on null and
|
||||
// closes; errors surface as an answer instead of silently swallowing.
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return result.text;
|
||||
}
|
||||
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||
// which starts a session of the default AI tool and shows it as chat.
|
||||
if (petChatTool()) {
|
||||
setChatPrompt(result.prompt);
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
return null;
|
||||
}
|
||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
brain.setThinking(true);
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const run = await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: prompt,
|
||||
permissionMode: "acceptEdits",
|
||||
const tool = readDefaultTool();
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||
});
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -312,9 +394,32 @@ function SagiBody() {
|
||||
})();
|
||||
return null;
|
||||
},
|
||||
[brain, navigate, t]
|
||||
[brain, navigate, t, name]
|
||||
);
|
||||
|
||||
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||
const onStroke = (e: React.PointerEvent) => {
|
||||
if (e.buttons !== 0 || e.pointerType === "touch") return;
|
||||
const s = stroke.current;
|
||||
const now = performance.now();
|
||||
const dx = e.clientX - s.x;
|
||||
s.x = e.clientX;
|
||||
if (Math.abs(dx) < 2 || now < s.until) return;
|
||||
const dir = Math.sign(dx);
|
||||
if (s.dir && dir !== s.dir) {
|
||||
s.turns = [...s.turns.filter((t0) => now - t0 < STROKE_MS), now];
|
||||
if (s.turns.length >= STROKE_TURNS) {
|
||||
s.turns = [];
|
||||
s.until = now + STROKE_COOLDOWN_MS;
|
||||
brain.pat();
|
||||
}
|
||||
}
|
||||
s.dir = dir;
|
||||
};
|
||||
|
||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
@@ -331,6 +436,13 @@ function SagiBody() {
|
||||
{!place.dragging && open && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SagiPanel
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
onPat={brain.pat}
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
@@ -342,6 +454,29 @@ function SagiBody() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1), and kept mounted: unmounting would detach its terminal and
|
||||
lose a half-typed message. */}
|
||||
{chatOpen && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
name={name}
|
||||
initialPrompt={chatPrompt}
|
||||
onPromptTaken={() => setChatPrompt(null)}
|
||||
onBack={() => {
|
||||
setChatOpen(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClose={() => setChatOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setChatOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!place.dragging && menu && (
|
||||
<SagiFlyout anchor={anchor} front>
|
||||
<PetMenu
|
||||
@@ -356,9 +491,13 @@ function SagiBody() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && brain.bubble && (
|
||||
{!open && !menu && !chatOpen && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
onDismiss={brain.dismissBubble}
|
||||
onOpen={openSession}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
@@ -370,34 +509,47 @@ function SagiBody() {
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerMove={(e) => {
|
||||
place.onPointerMove(e);
|
||||
onStroke(e);
|
||||
}}
|
||||
onPointerUp={place.onPointerUp}
|
||||
onKeyDown={place.onKeyDown}
|
||||
onClick={() => {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
setOpen((v) => !v);
|
||||
if (chatOpen) setChatOpen(false);
|
||||
else setOpen((v) => !v);
|
||||
}}
|
||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
|
||||
aria-expanded={open}
|
||||
title={t("sagi.avatarTitle")}
|
||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open || chatOpen}
|
||||
title={t("sagi.avatarTitle", { name })}
|
||||
>
|
||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
|
||||
<SagiAvatar
|
||||
mood={brain.mood}
|
||||
moment={reducedMotion ? null : brain.moment?.kind}
|
||||
reducedMotion={reducedMotion}
|
||||
size={size}
|
||||
/>
|
||||
) : (
|
||||
<PetSprite
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||
// A moment (a pat's hop) outranks the hover wave, or stroking the
|
||||
// pet would only ever show it waving.
|
||||
action={place.moving ? `run-${place.moving}` : hover && !brain.moment ? "wave" : null}
|
||||
moment={brain.moment?.kind}
|
||||
gaze={gaze}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
@@ -405,10 +557,28 @@ function SagiBody() {
|
||||
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
/>
|
||||
)}
|
||||
{brain.status.errorCount > 0 && (
|
||||
{brain.reaction && (
|
||||
<span key={brain.reaction.key} className="sagi-reaction" aria-live="polite">
|
||||
<span className="sagi-hearts" aria-hidden>
|
||||
<span>💜</span>
|
||||
<span>💗</span>
|
||||
<span>💜</span>
|
||||
</span>
|
||||
<span className="sagi-reaction-text" data-side={place.side}>
|
||||
{brain.reaction.text}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{brain.status.errorCount > 0 ? (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
</span>
|
||||
) : (
|
||||
brain.status.waitingCount > 0 && (
|
||||
<span className="sagi-error-dot" data-kind="waiting" aria-hidden>
|
||||
{brain.status.waitingCount > 9 ? "9+" : brain.status.waitingCount}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -37,12 +37,14 @@
|
||||
* ============================================================================= */
|
||||
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
interface SagiAvatarProps {
|
||||
mood: Mood;
|
||||
reducedMotion: boolean;
|
||||
size?: number;
|
||||
/** A one-shot reaction (hop, wiggle, flinch…) played over the mood. */
|
||||
moment?: PetMoment | null;
|
||||
}
|
||||
|
||||
const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space
|
||||
@@ -88,6 +90,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
mood,
|
||||
reducedMotion,
|
||||
size = 68,
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
@@ -148,6 +151,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
ref={rootRef}
|
||||
className="sagi-avatar"
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
data-reduced={reducedMotion ? "1" : "0"}
|
||||
width={size}
|
||||
height={size}
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Play,
|
||||
Activity,
|
||||
LayoutList,
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
@@ -61,12 +58,29 @@ import {
|
||||
AlertTriangle,
|
||||
Hourglass,
|
||||
Radio,
|
||||
Heart,
|
||||
ChevronRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiStatus } from "./brain";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
|
||||
interface SagiPanelProps {
|
||||
/** The name the pet goes by now. */
|
||||
name: string;
|
||||
/** The pet's own name, which an empty nickname falls back to. */
|
||||
ownName: string;
|
||||
/** The user's nickname for the pet, "" when none. */
|
||||
nickname: string;
|
||||
onRename: (name: string) => void;
|
||||
onPat: () => void;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: ReadonlyArray<AttentionItem>;
|
||||
onOpenSession: (s: SagiSubject) => void;
|
||||
status: SagiStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
@@ -78,6 +92,13 @@ interface SagiPanelProps {
|
||||
}
|
||||
|
||||
export function SagiPanel({
|
||||
name,
|
||||
ownName,
|
||||
nickname,
|
||||
onRename,
|
||||
onPat,
|
||||
attention,
|
||||
onOpenSession,
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
@@ -89,6 +110,13 @@ export function SagiPanel({
|
||||
const { t } = useTranslation("settings");
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
// The name being typed, or null while not renaming.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const saveName = () => {
|
||||
if (renaming === null) return;
|
||||
onRename(renaming);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -101,7 +129,7 @@ export function SagiPanel({
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label")}
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
@@ -109,7 +137,40 @@ export function SagiPanel({
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
|
||||
{renaming === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||
onClick={() => setRenaming(nickname)}
|
||||
aria-label={t("sagi.panel.rename", { name })}
|
||||
title={t("sagi.panel.rename", { name })}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||
value={renaming}
|
||||
maxLength={PET_NAME_MAX}
|
||||
placeholder={ownName}
|
||||
aria-label={t("sagi.panel.renameLabel")}
|
||||
title={t("sagi.panel.renameHint")}
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={saveName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveName();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc cancels the rename only, not the whole panel.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setRenaming(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected
|
||||
@@ -126,13 +187,23 @@ export function SagiPanel({
|
||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close")}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={onPat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
@@ -158,29 +229,55 @@ export function SagiPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* the sessions that need the user, by name */}
|
||||
{attention.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.panel.attention")}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
||||
onClick={() => onOpenSession(item)}
|
||||
title={item.cwd ?? item.id}
|
||||
>
|
||||
{item.kind === "error" ? (
|
||||
<AlertTriangle
|
||||
size={13}
|
||||
className="shrink-0 text-status-danger"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
||||
{subjectLabel(item)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">
|
||||
{item.kind === "error"
|
||||
? t("sagi.panel.attentionError")
|
||||
: t("sagi.panel.attentionWaiting")}
|
||||
</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{attention.length > ATTENTION_SHOWN && (
|
||||
<button
|
||||
className="mt-1 text-[11px] text-accent hover:underline"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
>
|
||||
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton
|
||||
icon={Play}
|
||||
label={t("sagi.panel.runClaude")}
|
||||
onClick={() => onNavigate("/workspace")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Activity}
|
||||
label={t("sagi.panel.activity")}
|
||||
onClick={() => onNavigate("/activity")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={LayoutList}
|
||||
label={t("sagi.panel.sessions")}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label={t("sagi.panel.errored")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||
@@ -198,10 +295,10 @@ export function SagiPanel({
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.panel.askPlaceholder")}
|
||||
placeholder={t("sagi.panel.askPlaceholder", { name })}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label={t("sagi.panel.ask")}
|
||||
aria-label={t("sagi.panel.ask", { name })}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -284,7 +381,7 @@ function ActionButton({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file SpeechBubble.tsx
|
||||
* @description Transient speech bubble rendered above the Sagi mascot.
|
||||
* Pure presentation — visibility timing and quip selection live in
|
||||
* {@link useSagiBrain}; this component only paints the bubble and handles
|
||||
* user dismissal.
|
||||
* Pure presentation — visibility timing, coalescing and quip selection live in
|
||||
* {@link useSagiBrain}; this component paints the line, the session(s) it is
|
||||
* about, an Open button when it is about exactly one, and handles dismissal.
|
||||
*
|
||||
* ## Accessibility
|
||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
||||
* to dismiss early.
|
||||
* new quips without interrupting current speech. Click the bubble to dismiss
|
||||
* it early; its Open button opens the session instead.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -52,30 +52,52 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiBubble } from "./useSagiBrain";
|
||||
import type { SagiSubject } from "./brain";
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
text: string;
|
||||
bubble: SagiBubble;
|
||||
/** Called when the user clicks to dismiss. */
|
||||
onDismiss: () => void;
|
||||
/** Opens the session the bubble is about. */
|
||||
onOpen?: (subject: SagiSubject) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated speech bubble above Sagi.
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
export function SpeechBubble({ bubble, onDismiss, onOpen }: SpeechBubbleProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { text, detail, subject, tone } = bubble;
|
||||
return (
|
||||
<div
|
||||
className="sagi-bubble sagi-bubble-enter"
|
||||
data-tone={tone}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
title={t("sagi.bubbleDismiss")}
|
||||
>
|
||||
{text}
|
||||
<div>{text}</div>
|
||||
{(detail || (subject && onOpen)) && (
|
||||
<div className="sagi-bubble-detail">
|
||||
{detail && <span className="sagi-bubble-subject">{detail}</span>}
|
||||
{subject && onOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="sagi-bubble-open"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen(subject);
|
||||
}}
|
||||
>
|
||||
{t("sagi.bubbleOpen")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file PetChat.test.ts
|
||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chatLines } from "../PetChat";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
||||
|
||||
describe("chatLines", () => {
|
||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
||||
const lines = chatLines([
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", text: "hmm" },
|
||||
{ type: "tool_use", name: "Read" },
|
||||
],
|
||||
}),
|
||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello!" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
||||
"hi",
|
||||
["Read", "Read"],
|
||||
"hello!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops Claude Code's own injected lines", () => {
|
||||
const lines = chatLines([
|
||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -261,7 +261,7 @@ describe("PetMenu", () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
});
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("PetMenu", () => {
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
|
||||
);
|
||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
@@ -313,11 +313,11 @@ describe("PetSettings", () => {
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image", name: "My cat" });
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
@@ -329,7 +329,7 @@ describe("PetSettings", () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -88,6 +88,93 @@ describe("Sagi widget", () => {
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the errored session in its bubble and in the panel", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
type: "session_updated",
|
||||
data: { id: "a", status: "error", name: "Fix login", cwd: "/w/app", lane_id: 3 } as Session,
|
||||
timestamp: "t",
|
||||
});
|
||||
});
|
||||
const bubble = screen.getByRole("status");
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet about a cleared error when that session updates again", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear alerts" }));
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||
expect(within(btn).queryByText("1")).not.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("renames the pet from the panel header", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands a question it cannot answer to the chat window", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the panel is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
const pet = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||
expect(pet.querySelector(".sagi-reaction-text")?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stroking the pet back and forth pats it", () => {
|
||||
renderSagi();
|
||||
const pet = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
const xs = [100, 130, 100, 130, 100, 130, 100];
|
||||
act(() => {
|
||||
for (const x of xs) fireEvent.pointerMove(pet, { clientX: x, clientY: 50, buttons: 0 });
|
||||
});
|
||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the live count in the panel status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
|
||||
@@ -225,6 +225,43 @@ describe("clearErrors", () => {
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
expect(s.worriedUntil).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a cleared session quiet while it stays errored", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
const again = reduceSagi(s, sessionMsg("b", "error", T0 + 1), T0 + 1);
|
||||
expect(again.pulse).toBeNull();
|
||||
expect(statusOf(again.state).errorCount).toBe(0);
|
||||
expect(again.state.worriedUntil).toBe(0);
|
||||
const ev = reduceSagi(
|
||||
again.state,
|
||||
{ type: "new_event", data: { session_id: "b", event_type: "toolError" }, timestamp: "" } as WSMessage,
|
||||
T0 + 2
|
||||
);
|
||||
expect(ev.pulse).toBeNull();
|
||||
expect(seedSessions(s, [{ id: "b", status: "error" }], T0).sessions.b).toBeUndefined();
|
||||
});
|
||||
|
||||
it("alerts again once a cleared session recovers and fails anew", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
|
||||
expect(s.dismissed).not.toHaveProperty("b");
|
||||
const r = reduceSagi(s, sessionMsg("b", "error"), T0);
|
||||
expect(r.pulse).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error alerts fire on the transition only", () => {
|
||||
it("does not re-alert on each update of a still-errored session", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "error"), T0));
|
||||
const r = reduceSagi(s, sessionMsg("a", "error"), T0 + 10_000);
|
||||
expect(r.pulse).toBeNull();
|
||||
expect(statusOf(r.state).errorCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Reference the imported constant so it is exercised and tsc-clean.
|
||||
|
||||
@@ -75,4 +75,27 @@ describe("matchIntent", () => {
|
||||
const r = matchIntent("Refactor my auth module", status());
|
||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
||||
});
|
||||
|
||||
it("understands Vietnamese keywords", () => {
|
||||
const r = matchIntent("có lỗi nào không?", status({ errorCount: 2 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("2 sessions errored");
|
||||
expect(matchIntent("đang chạy gì vậy", status({ liveCount: 1 })).kind).toBe("answer");
|
||||
expect(matchIntent("tình hình sao rồi", status()).kind).toBe("answer");
|
||||
});
|
||||
|
||||
it("names the sessions it reports when it knows them", () => {
|
||||
const r = matchIntent("any errors?", status({ errorCount: 1 }), {
|
||||
attention: [{ id: "a", kind: "error", name: "Fix login", cwd: null, lane_id: null }],
|
||||
});
|
||||
if (r.kind === "answer") expect(r.text).toContain("errored: Fix login");
|
||||
});
|
||||
|
||||
it("introduces itself by the pet's name and reacts to a pat", () => {
|
||||
const who = matchIntent("who are you", status(), { name: "Mochi" });
|
||||
expect(who).toMatchObject({ kind: "answer", moment: "wave" });
|
||||
if (who.kind === "answer") expect(who.text).toContain("Mochi");
|
||||
expect(matchIntent("pat", status())).toMatchObject({ kind: "answer", moment: "jump" });
|
||||
// "pat" inside another word is not a pat.
|
||||
expect(matchIntent("update the path", status()).kind).toBe("handoff");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,14 +137,7 @@ import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from
|
||||
|
||||
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||
@@ -152,13 +145,21 @@ export type Mood =
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type SagiPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
|
||||
|
||||
/** A short animation the pet plays on an event or a pat, over its mood. */
|
||||
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
|
||||
|
||||
/** Which moment each pulse plays: greet a new session, hop at a finish,
|
||||
* inspect a new subagent, tap a foot while you are awaited, flinch at an error. */
|
||||
export const MOMENT_OF_PULSE: Record<NonNullable<SagiPulse>, PetMoment> = {
|
||||
session_start: "wave",
|
||||
session_done: "jump",
|
||||
run_done: "jump",
|
||||
subagent_spawn: "review",
|
||||
waiting: "wait",
|
||||
error: "fail",
|
||||
};
|
||||
|
||||
export interface SagiStatus {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
@@ -182,6 +183,47 @@ export interface SagiState {
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
thinking: boolean;
|
||||
/** Errored sessions the user cleared ("clear alerts"). Their further
|
||||
* updates, agent errors and failure events stay silent until the session
|
||||
* goes active again or ends — otherwise every update of a still-errored
|
||||
* session re-raised the alert the user had just dismissed. */
|
||||
dismissed: Record<string, true>;
|
||||
/** Name and owning workspace of each tracked session, so a bubble or the
|
||||
* panel can say WHICH session it means and open it. */
|
||||
info: Record<string, SessionInfo>;
|
||||
}
|
||||
|
||||
/** What Sagi knows about a session beyond its status. */
|
||||
export interface SessionInfo {
|
||||
name: string | null;
|
||||
cwd: string | null;
|
||||
lane_id: number | null;
|
||||
}
|
||||
|
||||
/** The session a pulse is about, when it is about one. */
|
||||
export interface SagiSubject extends SessionInfo {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** A session that needs the user: errored, or waiting on their input. */
|
||||
export interface AttentionItem extends SagiSubject {
|
||||
kind: "error" | "waiting";
|
||||
}
|
||||
|
||||
const infoOf = (s: {
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}): SessionInfo => ({ name: s.name ?? null, cwd: s.cwd ?? null, lane_id: s.lane_id ?? null });
|
||||
|
||||
/** A session's label for a bubble: its name, else its folder, else a short id. */
|
||||
export function subjectLabel(s: SagiSubject): string {
|
||||
if (s.name && s.name.trim()) return s.name.trim();
|
||||
const base = s.cwd
|
||||
?.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop();
|
||||
return base || s.id.slice(0, 8);
|
||||
}
|
||||
|
||||
// Tunable timing constants (ms).
|
||||
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
dismissed: {},
|
||||
info: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,16 +300,28 @@ export function reduceSagi(
|
||||
state: SagiState,
|
||||
msg: WSMessage,
|
||||
now: number
|
||||
): { state: SagiState; pulse: SagiPulse } {
|
||||
): { state: SagiState; pulse: SagiPulse; subject?: SagiSubject } {
|
||||
// The tracked session an agent/event message belongs to, when known.
|
||||
const known = (id: string | undefined): SagiSubject | undefined =>
|
||||
id ? { id, ...(state.info[id] ?? { name: null, cwd: null, lane_id: null }) } : undefined;
|
||||
switch (msg.type) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info, [s.id]: infoOf(s) };
|
||||
const subject: SagiSubject = { id: s.id, ...info[s.id]! };
|
||||
let dismissed = state.dismissed;
|
||||
let pulse: SagiPulse = null;
|
||||
let happyUntil = state.happyUntil;
|
||||
let worriedUntil = state.worriedUntil;
|
||||
// Anything but a (still) errored status ends a dismissal: a later error
|
||||
// of that session is a new one and must alert again.
|
||||
if (s.status !== "error" && s.id in dismissed) {
|
||||
dismissed = { ...dismissed };
|
||||
delete dismissed[s.id];
|
||||
}
|
||||
|
||||
if (s.status === "active") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
@@ -280,12 +336,20 @@ export function reduceSagi(
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} else if (s.status === "error") {
|
||||
if (s.id in dismissed) {
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
// Alert on the transition INTO error only; a session that stays
|
||||
// errored keeps getting updates and must not re-alert on each one.
|
||||
if (sessions[s.id] !== "error") {
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
}
|
||||
sessions[s.id] = "error";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
delete info[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
@@ -293,31 +357,46 @@ export function reduceSagi(
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
state: {
|
||||
...state,
|
||||
sessions,
|
||||
dismissed,
|
||||
info,
|
||||
happyUntil,
|
||||
worriedUntil,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
pulse,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
||||
return {
|
||||
state: { ...state, lastActivityAt: now },
|
||||
pulse,
|
||||
subject: pulse ? known(a.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_updated": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
@@ -325,7 +404,8 @@ export function reduceSagi(
|
||||
|
||||
case "new_event": {
|
||||
const e = msg.data as DashboardEvent;
|
||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
||||
const isFailure =
|
||||
!!e && FAILURE_EVENT_TYPES.has(e.event_type) && !(e.session_id in state.dismissed);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
@@ -333,6 +413,7 @@ export function reduceSagi(
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
subject: isFailure ? known(e.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,24 +442,60 @@ export function reduceSagi(
|
||||
*/
|
||||
export function seedSessions(
|
||||
state: SagiState,
|
||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||
rows: ReadonlyArray<{
|
||||
id: string;
|
||||
status: string;
|
||||
awaiting_input_since?: string | null;
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}>,
|
||||
now: number
|
||||
): SagiState {
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
if (r.status === "error") {
|
||||
if (r.id in state.dismissed) continue;
|
||||
sessions[r.id] = "error";
|
||||
} else if (r.status === "active") {
|
||||
sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
} else continue; // completed / abandoned: leave untracked.
|
||||
info[r.id] = infoOf(r);
|
||||
}
|
||||
return { ...state, sessions, lastActivityAt: now };
|
||||
return { ...state, sessions, info, lastActivityAt: now };
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||
/** Errored sessions first, then the ones waiting on the user — the panel's list. */
|
||||
export function attentionOf(state: SagiState): AttentionItem[] {
|
||||
const out: AttentionItem[] = [];
|
||||
for (const kind of ["error", "waiting"] as const) {
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== kind) continue;
|
||||
out.push({
|
||||
id,
|
||||
kind,
|
||||
...(state.info[id] ?? { name: null, cwd: null, lane_id: null }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts") and
|
||||
* remember them as dismissed, so their next update does not bring the alert
|
||||
* straight back. */
|
||||
export function clearErrors(state: SagiState): SagiState {
|
||||
const sessions: SagiState["sessions"] = {};
|
||||
const dismissed = { ...state.dismissed };
|
||||
const info = { ...state.info };
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== "error") sessions[id] = s;
|
||||
else {
|
||||
dismissed[id] = true;
|
||||
delete info[id];
|
||||
}
|
||||
}
|
||||
return { ...state, sessions, worriedUntil: 0 };
|
||||
return { ...state, sessions, dismissed, info, worriedUntil: 0 };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
||||
* small set of intents answerable from cached dashboard status. Anything it
|
||||
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
||||
* Pure function - no network, no DOM - so it's fully unit-testable.
|
||||
* Understands English and Vietnamese keywords, names the sessions it reports
|
||||
* when it knows them, and answers in the UI language (`settings:sagi.intent`).
|
||||
* No network, no DOM - so it's fully unit-testable.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -28,6 +30,8 @@
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AskContext` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `statusSummary` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -54,70 +58,157 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { SagiStatus } from "./brain";
|
||||
import i18n from "../../i18n";
|
||||
import { subjectLabel, type AttentionItem, type PetMoment, type SagiStatus } from "./brain";
|
||||
import { pickLine } from "./quips";
|
||||
|
||||
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
|
||||
export type AskResult =
|
||||
{ kind: "answer"; text: string; moment?: PetMoment } | { kind: "handoff"; prompt: string };
|
||||
|
||||
const plural = (n: number) => (n === 1 ? "" : "s");
|
||||
/** What the answer may mention beyond the counts. */
|
||||
export interface AskContext {
|
||||
/** The pet's shown name. */
|
||||
name?: string;
|
||||
/** Errored and waiting sessions, named (`attentionOf`). */
|
||||
attention?: ReadonlyArray<AttentionItem>;
|
||||
}
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus): AskResult {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||
};
|
||||
}
|
||||
const t = (key: string, vars?: Record<string, unknown>) =>
|
||||
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
|
||||
|
||||
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
|
||||
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
|
||||
const names = (items ?? []).filter((i) => i.kind === kind).map(subjectLabel);
|
||||
if (names.length === 0) return "";
|
||||
const shown = names.slice(0, 3).join(", ");
|
||||
return `: ${names.length > 3 ? `${shown}…` : shown}`;
|
||||
}
|
||||
|
||||
/** "2 sessions live, 1 waiting on you" — the one-line status a greeting carries. */
|
||||
export function statusSummary(status: SagiStatus): string {
|
||||
const parts = [i18n.t("settings:sagi.greet.summary", { count: status.liveCount })];
|
||||
if (status.waitingCount > 0)
|
||||
parts.push(i18n.t("settings:sagi.greet.summaryWaiting", { count: status.waitingCount }));
|
||||
if (status.errorCount > 0)
|
||||
parts.push(i18n.t("settings:sagi.greet.summaryErrors", { count: status.errorCount }));
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus, ctx: AskContext = {}): AskResult {
|
||||
const q = query.trim().toLowerCase().normalize("NFC");
|
||||
const name = ctx.name || "Sagi";
|
||||
if (!q) return { kind: "answer", text: t("empty") };
|
||||
|
||||
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
||||
// Whole-word match, for short words that hide inside longer ones ("hi" in "this").
|
||||
const word = (...words: string[]) =>
|
||||
words.some((w) => new RegExp(`(^|[^\\p{L}])${w}($|[^\\p{L}])`, "u").test(q));
|
||||
|
||||
if (has("help", "what can you", "what do you do")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
||||
};
|
||||
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
|
||||
return { kind: "answer", text: t("help", { name }) };
|
||||
}
|
||||
|
||||
if (has("who are you", "your name", "bạn là ai", "tên gì", "tên bạn", "là ai")) {
|
||||
return { kind: "answer", text: t("whoami", { name }), moment: "wave" };
|
||||
}
|
||||
|
||||
if (has("thank", "thx", "cảm ơn", "cám ơn") || word("ty", "tks")) {
|
||||
return { kind: "answer", text: pickLine("intent.thanks"), moment: "jump" };
|
||||
}
|
||||
|
||||
if (
|
||||
has("good boy", "good girl", "cute", "ngoan", "dễ thương", "xoa đầu", "vuốt ve", "giỏi quá") ||
|
||||
word("pat")
|
||||
) {
|
||||
return { kind: "answer", text: pickLine("pat"), moment: "jump" };
|
||||
}
|
||||
|
||||
// Errors first: "any failed runs" should report errors, not live count.
|
||||
if (has("error", "broke", "broken", "fail", "wrong", "crash")) {
|
||||
if (
|
||||
has("error", "broke", "broken", "fail", "wrong", "crash", "lỗi", "hỏng", "thất bại", "sự cố")
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.errorCount > 0
|
||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
||||
: "no errors - all clean 🐾",
|
||||
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
|
||||
: t("errorsNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) {
|
||||
if (
|
||||
has(
|
||||
"waiting",
|
||||
"stuck",
|
||||
"blocked",
|
||||
"input",
|
||||
"my turn",
|
||||
"paused",
|
||||
"chờ",
|
||||
"đợi",
|
||||
"kẹt",
|
||||
"tới lượt",
|
||||
"đến lượt"
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.waitingCount > 0
|
||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
||||
: "nothing's waiting on you right now 🐾",
|
||||
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
|
||||
: t("waitingNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
||||
if (
|
||||
has(
|
||||
"running",
|
||||
"active",
|
||||
"live",
|
||||
"going on",
|
||||
"happening",
|
||||
"in progress",
|
||||
"đang chạy",
|
||||
"đang làm",
|
||||
"hoạt động"
|
||||
)
|
||||
) {
|
||||
const tail = status.waitingCount > 0 ? t("liveTail", { count: status.waitingCount }) : "";
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.liveCount > 0
|
||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
||||
: "nothing's running right now - all quiet.",
|
||||
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) {
|
||||
if (
|
||||
has(
|
||||
"status",
|
||||
"summary",
|
||||
"overview",
|
||||
"how are things",
|
||||
"how's it",
|
||||
"how is it",
|
||||
"tình hình",
|
||||
"trạng thái",
|
||||
"tổng quan",
|
||||
"thế nào",
|
||||
"sao rồi"
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
||||
status.connected ? "connected" : "offline"
|
||||
}.`,
|
||||
text: t("status", {
|
||||
live: status.liveCount,
|
||||
waiting: status.waitingCount,
|
||||
errors: status.errorCount,
|
||||
conn: t(status.connected ? "connected" : "offline"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (word("hi", "hello", "hey", "yo", "chào", "xin chào", "alo")) {
|
||||
return { kind: "answer", text: t("hello", { summary: statusSummary(status) }), moment: "wave" };
|
||||
}
|
||||
|
||||
return { kind: "handoff", prompt: query.trim() };
|
||||
}
|
||||
|
||||
@@ -56,12 +56,19 @@ const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||
const PET_KEY = "agenthub.pet";
|
||||
const SIZE_KEY = "agenthub.pet.size";
|
||||
const DISMISSED_KEY = "agenthub.pet.dismissedErrors";
|
||||
/** Per-pet nicknames the user typed, `{ [petId]: name }`. */
|
||||
const NAMES_KEY = "agenthub.pet.names";
|
||||
export const PET_NAME_MAX = 40;
|
||||
/** Most recent dismissed error sessions kept across reloads. */
|
||||
const DISMISSED_MAX = 200;
|
||||
/** Sagi's own size, and the slider's range. */
|
||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||
|
||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||
* The kind rides along so the mascot renders without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
||||
* The kind (and its display name) ride along so the mascot renders, and
|
||||
* introduces itself, without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
|
||||
const SAGI: PetChoice = { id: "sagi" };
|
||||
|
||||
function readPet(): PetChoice {
|
||||
@@ -69,6 +76,7 @@ function readPet(): PetChoice {
|
||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}> | null;
|
||||
if (
|
||||
p &&
|
||||
@@ -76,13 +84,34 @@ function readPet(): PetChoice {
|
||||
p.id !== "sagi" &&
|
||||
(p.kind === "codex" || p.kind === "image")
|
||||
)
|
||||
return { id: p.id, kind: p.kind };
|
||||
return typeof p.name === "string" && p.name
|
||||
? { id: p.id, kind: p.kind, name: p.name.slice(0, PET_NAME_MAX) }
|
||||
: { id: p.id, kind: p.kind };
|
||||
} catch {
|
||||
/* unreadable: Sagi */
|
||||
}
|
||||
return SAGI;
|
||||
}
|
||||
|
||||
function readNames(): Record<string, string> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(NAMES_KEY) ?? "{}") as unknown;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, v] of Object.entries(raw)) if (typeof v === "string" && v) out[id] = v;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The name the pet goes by: the user's nickname, else the pet's own, else Sagi. */
|
||||
export function petName(pet: PetChoice): string {
|
||||
const nick = readNames()[pet.id];
|
||||
if (nick) return nick;
|
||||
return ("name" in pet && pet.name) || "Sagi";
|
||||
}
|
||||
|
||||
function readSize(): number {
|
||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||
return Number.isFinite(n) && n > 0
|
||||
@@ -181,6 +210,37 @@ export const sagiPrefs = {
|
||||
SIZE_KEY,
|
||||
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
||||
),
|
||||
/** Name a pet; an empty name gives it back its own. */
|
||||
setNickname: (id: string, name: string) => {
|
||||
const names = readNames();
|
||||
const clean = name.replace(/\s+/g, " ").trim().slice(0, PET_NAME_MAX);
|
||||
if (clean) names[id] = clean;
|
||||
else delete names[id];
|
||||
writeAndNotify(NAMES_KEY, JSON.stringify(names));
|
||||
},
|
||||
/** The user's nickname for a pet, "" when none. */
|
||||
getNickname: (id: string) => readNames()[id] ?? "",
|
||||
/** Errored session ids the user cleared, so a reload keeps them quiet. */
|
||||
getDismissed: (): Record<string, true> => {
|
||||
try {
|
||||
const ids = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]");
|
||||
const out: Record<string, true> = {};
|
||||
if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") out[id] = true;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
setDismissed: (dismissed: Record<string, true>) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DISMISSED_KEY,
|
||||
JSON.stringify(Object.keys(dismissed).slice(-DISMISSED_MAX))
|
||||
);
|
||||
} catch {
|
||||
// Best-effort, like every pref here; no prefs event — widget-local state.
|
||||
}
|
||||
},
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file quips.ts
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
|
||||
* plus a deterministic-by-injection picker. Pure data + a pure function so it
|
||||
* can be unit-tested without randomness leaking in.
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood
|
||||
* (and greetings, pat reactions), read from the `settings:sagi.*` i18n arrays
|
||||
* so they follow the UI language, plus a deterministic-by-injection picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -24,10 +24,13 @@
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
* - `../../i18n`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickLine` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `greetingKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -59,76 +62,60 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import type { Mood, SagiPulse } from "./brain";
|
||||
|
||||
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
||||
|
||||
const QUIPS: Record<QuipKey, string[]> = {
|
||||
export const ALL_QUIP_KEYS: QuipKey[] = [
|
||||
// Pulses (event-driven, transient bubbles)
|
||||
session_done: [
|
||||
"a session just wrapped up! 🐾",
|
||||
"a session finished - nice work! ✨",
|
||||
"that session's all done 😺",
|
||||
"clean finish on that one 💜",
|
||||
],
|
||||
session_start: [
|
||||
"a new session started! 👀",
|
||||
"a fresh session just landed 🐾",
|
||||
"ooh, a new session to watch 😻",
|
||||
"something new is cooking 🍲",
|
||||
],
|
||||
subagent_spawn: [
|
||||
"a subagent just spawned! 🐾",
|
||||
"a little helper joined in 🤝",
|
||||
"a subagent's on the job 🚀",
|
||||
"reinforcements - new subagent! 😺",
|
||||
],
|
||||
waiting: [
|
||||
"a session needs your input 👀",
|
||||
"a session is waiting on you ⏳",
|
||||
"a session paused for your reply 💬",
|
||||
"your turn - a session's waiting 🐾",
|
||||
],
|
||||
error: [
|
||||
"uh oh, a session hit an error 😿",
|
||||
"something broke - wanna peek? 🙀",
|
||||
"a hook tripped on something ⚠️",
|
||||
"hiss… an error popped up 💢",
|
||||
],
|
||||
run_done: [
|
||||
"your run just finished! 🐾",
|
||||
"the run's all wrapped up ✨",
|
||||
"run complete - that's a wrap 😸",
|
||||
"all done with that run 💜",
|
||||
],
|
||||
// Moods (steady-state flavor, used by the panel / idle bubbles)
|
||||
disconnected: [
|
||||
"lost the connection… 😴",
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤",
|
||||
],
|
||||
worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
stuck: [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾",
|
||||
],
|
||||
happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"],
|
||||
};
|
||||
"session_done",
|
||||
"session_start",
|
||||
"subagent_spawn",
|
||||
"waiting",
|
||||
"error",
|
||||
"run_done",
|
||||
// Moods (steady-state flavor)
|
||||
"disconnected",
|
||||
"worried",
|
||||
"stuck",
|
||||
"happy",
|
||||
"thinking",
|
||||
"watching",
|
||||
"sleeping",
|
||||
"idle",
|
||||
];
|
||||
const KNOWN = new Set<string>(ALL_QUIP_KEYS);
|
||||
|
||||
/**
|
||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
||||
* Pick one line from an i18n string array under `settings:sagi.<path>`.
|
||||
* `rand` is injectable for deterministic tests. Returns "" for a missing or
|
||||
* empty pool (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
const pool = QUIPS[key];
|
||||
if (!pool || pool.length === 0) return "";
|
||||
export function pickLine(
|
||||
path: string,
|
||||
rand: () => number = Math.random,
|
||||
vars?: Record<string, unknown>
|
||||
): string {
|
||||
const pool = i18n.t(`settings:sagi.${path}`, { returnObjects: true, ...vars }) as unknown;
|
||||
if (!Array.isArray(pool) || pool.length === 0) return "";
|
||||
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
|
||||
return pool[i] ?? "";
|
||||
const line = pool[i];
|
||||
return typeof line === "string" ? line : "";
|
||||
}
|
||||
|
||||
export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[];
|
||||
/**
|
||||
* Pick a quip for a key, in the UI language. Returns "" only for an unknown
|
||||
* key (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
return KNOWN.has(key) ? pickLine(`quips.${key}`, rand) : "";
|
||||
}
|
||||
|
||||
/** Which greeting pool fits the hour: morning 5–11, afternoon 12–17, evening 18–22, else night. */
|
||||
export function greetingKey(hour: number): "morning" | "afternoon" | "evening" | "night" {
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 18) return "afternoon";
|
||||
if (hour >= 18 && hour < 23) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* sagi.css - animations + mood expressions for the Sagi companion.
|
||||
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
|
||||
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
|
||||
* forever: each plays a few cycles when the mood begins and then rests — an
|
||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||
* which made the whole desktop stutter. All motion is disabled under
|
||||
* [data-reduced="1"] and prefers-reduced-motion.
|
||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -206,20 +210,20 @@
|
||||
|
||||
/* idle: gentle breathing + occasional blink */
|
||||
.sagi-avatar[data-mood="idle"] {
|
||||
animation: sagi-breathe 4s ease-in-out infinite;
|
||||
animation: sagi-breathe 4s ease-in-out 0s 3;
|
||||
}
|
||||
/* Blink lives on the inner group so it never overrides the outer group's
|
||||
eye-tracking translate (which would freeze the eyes - see the CSS-blink
|
||||
note in SagiAvatar.tsx). */
|
||||
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
|
||||
animation: sagi-blink 5s ease-in-out infinite;
|
||||
animation: sagi-blink 5s ease-in-out 0s 3;
|
||||
}
|
||||
/* happy: whole-mascot bob + twinkling sparkle */
|
||||
.sagi-avatar[data-mood="happy"] {
|
||||
animation: sagi-bob 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
|
||||
}
|
||||
/* worried: alarmed shake */
|
||||
@@ -228,19 +232,97 @@
|
||||
}
|
||||
/* stuck: slow, drooping breathe (fatigued) */
|
||||
.sagi-avatar[data-mood="stuck"] {
|
||||
animation: sagi-droop 2.4s ease-in-out infinite;
|
||||
animation: sagi-droop 2.4s ease-in-out 0s 3;
|
||||
}
|
||||
/* thinking: rocking head tilt */
|
||||
.sagi-avatar[data-mood="thinking"] {
|
||||
animation: sagi-rock 1.8s ease-in-out infinite;
|
||||
animation: sagi-rock 1.8s ease-in-out 0s 4;
|
||||
}
|
||||
/* watching: attentive sway while a session is live */
|
||||
.sagi-avatar[data-mood="watching"] {
|
||||
animation: sagi-sway 3.2s ease-in-out infinite;
|
||||
animation: sagi-sway 3.2s ease-in-out 0s 3;
|
||||
}
|
||||
/* sleeping: slow breathe, droop */
|
||||
.sagi-avatar[data-mood="sleeping"] {
|
||||
animation: sagi-breathe 5s ease-in-out infinite;
|
||||
animation: sagi-breathe 5s ease-in-out 0s 3;
|
||||
}
|
||||
|
||||
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
|
||||
over the mood for ~2.4 s. Declared after the moods so they win the cascade.
|
||||
Shared by Sagi's own art and an uploaded still image. ── */
|
||||
.sagi-avatar[data-moment="jump"],
|
||||
.sagi-pet-image[data-moment="jump"] {
|
||||
animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3;
|
||||
}
|
||||
.sagi-avatar[data-moment="wave"],
|
||||
.sagi-pet-image[data-moment="wave"] {
|
||||
animation: sagi-wiggle 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-moment="fail"],
|
||||
.sagi-pet-image[data-moment="fail"] {
|
||||
animation: sagi-shake 0.35s ease-in-out 0s 5;
|
||||
}
|
||||
.sagi-avatar[data-moment="wait"],
|
||||
.sagi-pet-image[data-moment="wait"] {
|
||||
animation: sagi-tap 0.4s ease-in-out 0s 5;
|
||||
}
|
||||
.sagi-avatar[data-moment="review"],
|
||||
.sagi-pet-image[data-moment="review"] {
|
||||
animation: sagi-peek 2.2s ease-in-out 0s 1;
|
||||
}
|
||||
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px;
|
||||
}
|
||||
@keyframes sagi-hop {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1, 1);
|
||||
}
|
||||
15% {
|
||||
transform: translateY(0) scale(1.08, 0.92);
|
||||
}
|
||||
45% {
|
||||
transform: translateY(-14%) scale(0.96, 1.05);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(0) scale(1.04, 0.96);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-9deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-tap {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4%);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-peek {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) translateX(0);
|
||||
}
|
||||
25%,
|
||||
40% {
|
||||
transform: rotate(-7deg) translateX(-4%);
|
||||
}
|
||||
60%,
|
||||
75% {
|
||||
transform: rotate(7deg) translateX(4%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── reduced motion: kill all continuous animation ── */
|
||||
@@ -339,7 +421,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out infinite;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out 0s 5;
|
||||
}
|
||||
/* Sessions waiting on the user, when none errored: the same dot, amber. */
|
||||
.sagi-error-dot[data-kind="waiting"] {
|
||||
background: #f59e0b;
|
||||
animation: none;
|
||||
}
|
||||
.sagi-pet-image {
|
||||
transform-origin: 50% 92%;
|
||||
}
|
||||
.sagi-bubble {
|
||||
max-width: 16rem;
|
||||
@@ -356,6 +446,41 @@
|
||||
.sagi-bubble-enter {
|
||||
animation: sagi-bubble-in 0.22s ease-out;
|
||||
}
|
||||
.sagi-bubble[data-tone="error"] {
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
.sagi-bubble[data-tone="waiting"] {
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
}
|
||||
/* The session(s) a bubble is about, and the button that opens it. */
|
||||
.sagi-bubble-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: #a8a8c0;
|
||||
}
|
||||
.sagi-bubble-subject {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sagi-bubble-open {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #c4b5fd;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sagi-bubble-open:hover {
|
||||
background: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
@keyframes sagi-bubble-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -375,7 +500,7 @@
|
||||
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
||||
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
||||
.sagi-pet-image {
|
||||
animation: sagi-pet-bob 2.6s ease-in-out infinite;
|
||||
animation: sagi-pet-bob 2.6s ease-in-out 0s 3;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.sagi-pet-image[data-mood="thinking"] {
|
||||
@@ -403,3 +528,165 @@
|
||||
.sagi-flyout[data-front="1"] {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
||||
|
||||
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
||||
colour: a tint of the accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. Replies sit on the plain surface like
|
||||
the reference's white bubbles; the user's own are accent-solid (white text
|
||||
is only ever on accent-solid). */
|
||||
.pet-chat {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%,
|
||||
color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%,
|
||||
rgb(var(--surface-1)) 100%
|
||||
);
|
||||
}
|
||||
.pet-chat-head {
|
||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||
}
|
||||
.pet-chat-reply {
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
||||
}
|
||||
.pet-chat-tools {
|
||||
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
||||
}
|
||||
.pet-chat-foot {
|
||||
background: rgb(var(--surface-0));
|
||||
border-top: 1px solid rgb(var(--border) / 0.6);
|
||||
}
|
||||
/* Three bouncing dots while a reply is written. */
|
||||
.pet-chat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--accent));
|
||||
opacity: 0.5;
|
||||
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
.pet-chat-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.pet-chat-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes pet-chat-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
||||
does not shrink the pane to fit it) and a non-zero box (so the paste
|
||||
registry counts it as on screen). */
|
||||
.pet-chat-term {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 760px;
|
||||
height: 460px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-chat-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||
|
||||
/* Hearts float up from the pet and its line shows beside it, inside the
|
||||
avatar button — so a pat reads even while the panel or the chat is open
|
||||
(the speech bubble hides then). */
|
||||
.sagi-reaction {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sagi-hearts {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 10%;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.sagi-hearts > span {
|
||||
animation: sagi-heart-up 1.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.sagi-hearts > span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
font-size: 18px;
|
||||
}
|
||||
.sagi-hearts > span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes sagi-heart-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.6);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-4px) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-34px) scale(0.9);
|
||||
}
|
||||
}
|
||||
/* The line sits on the side away from the screen edge the pet docks to. */
|
||||
.sagi-reaction-text {
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
white-space: nowrap;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent);
|
||||
color: rgb(var(--fg-primary));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1rem;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
animation: sagi-reaction-in 2.4s ease-out forwards;
|
||||
}
|
||||
.sagi-reaction-text[data-side="right"] {
|
||||
right: calc(100% + 6px);
|
||||
}
|
||||
.sagi-reaction-text[data-side="left"] {
|
||||
left: calc(100% + 6px);
|
||||
}
|
||||
@keyframes sagi-reaction-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
10%,
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sagi-hearts > span,
|
||||
.sagi-reaction-text {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function usePets() {
|
||||
}
|
||||
};
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind, name: p.displayName });
|
||||
const install = (featuredId: string) =>
|
||||
run(featuredId, async () => {
|
||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
* @file useSagiBrain.ts
|
||||
* @description React hook that wires the pure Sagi brain to the live event bus
|
||||
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
||||
* exposes the derived mood, a status summary, the current speech bubble, and
|
||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
||||
* exposes the derived mood, a status summary, the sessions that need the user,
|
||||
* the current speech bubble (naming the session it is about, and coalescing a
|
||||
* burst of the same event into one count), the pet's one-shot animation
|
||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||
* for the UI shell. It also greets once per browser session and makes an
|
||||
* idle pet fidget now and then.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -62,127 +66,303 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { api } from "../../lib/api";
|
||||
import i18n from "../../i18n";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
import {
|
||||
initialSagiState,
|
||||
reduceSagi,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
attentionOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
subjectLabel,
|
||||
MOMENT_OF_PULSE,
|
||||
type AttentionItem,
|
||||
type Mood,
|
||||
type PetMoment,
|
||||
type SagiPulse,
|
||||
type SagiState,
|
||||
type SagiStatus,
|
||||
type SagiSubject,
|
||||
} from "./brain";
|
||||
import { pickQuip } from "./quips";
|
||||
import { greetingKey, pickLine, pickQuip } from "./quips";
|
||||
import { statusSummary } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
/** `next` differs from `prev` only by a lastActivityAt less than a second
|
||||
* newer — nothing the mascot shows, at the moods' 1 s resolution. */
|
||||
function onlyActivityMoved(prev: SagiState, next: SagiState): boolean {
|
||||
if (next === prev) return true;
|
||||
if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false;
|
||||
for (const k of Object.keys(next) as (keyof SagiState)[]) {
|
||||
if (k !== "lastActivityAt" && next[k] !== prev[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const BUBBLE_MS = 4500;
|
||||
// A bubble that names a session stays long enough to click it.
|
||||
const SUBJECT_BUBBLE_MS = 7000;
|
||||
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||
const BUBBLE_THROTTLE_MS = 3000;
|
||||
// The same pulse again within this window joins the bubble on show as a count.
|
||||
const COALESCE_MS = 8000;
|
||||
// How long a moment (wave, hop, flinch…) plays over the mood.
|
||||
const MOMENT_MS = 2400;
|
||||
// An idle pet fidgets (waves or looks around) once in a while.
|
||||
const FIDGET_MIN_MS = 45_000;
|
||||
const FIDGET_SPAN_MS = 60_000;
|
||||
// Greet once per browser session, shortly after the snapshot lands.
|
||||
const GREETED_KEY = "agenthub.pet.greeted";
|
||||
const GREET_DELAY_MS = 1200;
|
||||
|
||||
/** What the speech bubble shows. */
|
||||
export interface SagiBubble {
|
||||
text: string;
|
||||
/** Which session(s) it is about, shown under the text. */
|
||||
detail?: string;
|
||||
/** The one session it is about, which a click opens. */
|
||||
subject?: SagiSubject;
|
||||
/** Errors and waits are drawn with an accent. */
|
||||
tone?: "error" | "waiting";
|
||||
}
|
||||
|
||||
export interface SagiBrain {
|
||||
mood: Mood;
|
||||
status: SagiStatus;
|
||||
bubble: string | null;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: AttentionItem[];
|
||||
bubble: SagiBubble | null;
|
||||
dismissBubble: () => void;
|
||||
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
||||
* errors). Bypasses the throttle because the caller decided this message
|
||||
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
||||
setBubble: (text: string) => void;
|
||||
/** The one-shot animation playing now, if any; `key` changes per moment so a
|
||||
* repeat of the same one restarts it. */
|
||||
moment: { kind: PetMoment; key: number } | null;
|
||||
/** Play a moment by hand (an answer, a pat). */
|
||||
playMoment: (kind: PetMoment) => void;
|
||||
/** Pat the pet: a hop, hearts and a happy line right on the pet — shown
|
||||
* even while the panel or chat is open, and while muted (it was asked). */
|
||||
pat: () => void;
|
||||
/** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */
|
||||
reaction: { text: string; key: number } | null;
|
||||
muted: boolean;
|
||||
toggleMute: () => void;
|
||||
clearAlerts: () => void;
|
||||
setThinking: (v: boolean) => void;
|
||||
}
|
||||
|
||||
type Burst = { pulse: NonNullable<SagiPulse>; count: number; subjects: SagiSubject[]; at: number };
|
||||
|
||||
function burstBubble(b: Burst): SagiBubble {
|
||||
const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined;
|
||||
const one = b.subjects.length === 1 ? b.subjects[0] : undefined;
|
||||
const labels = b.subjects.map(subjectLabel);
|
||||
const detail =
|
||||
labels.length > 2
|
||||
? i18n.t("settings:sagi.bubbleMore", {
|
||||
list: labels.slice(0, 2).join(", "),
|
||||
count: labels.length - 2,
|
||||
})
|
||||
: labels.join(", ") || undefined;
|
||||
const text =
|
||||
b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse);
|
||||
return { text, detail, subject: one, tone };
|
||||
}
|
||||
|
||||
export function useSagiBrain(): SagiBrain {
|
||||
const now0 = Date.now();
|
||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
||||
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||
const [state, setState] = useState<SagiState>(() => ({
|
||||
const [state, setStateRaw] = useState<SagiState>(() => ({
|
||||
...initialSagiState(now0),
|
||||
connected: true,
|
||||
dismissed: sagiPrefs.getDismissed(),
|
||||
}));
|
||||
// The reducer runs OUTSIDE React's updater (which StrictMode calls twice),
|
||||
// so a pulse notifies exactly once; this ref is the state it folds into.
|
||||
const stateRef = useRef(state);
|
||||
const update = useCallback((fn: (prev: SagiState) => SagiState) => {
|
||||
const next = fn(stateRef.current);
|
||||
if (next === stateRef.current) return;
|
||||
stateRef.current = next;
|
||||
setStateRaw(next);
|
||||
}, []);
|
||||
|
||||
const [tick, setTick] = useState(now0);
|
||||
const [bubble, setBubbleState] = useState<string | null>(null);
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
const mutedRef = useRef(muted);
|
||||
mutedRef.current = muted;
|
||||
|
||||
// Keep cleared alerts cleared across reloads.
|
||||
useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]);
|
||||
|
||||
// Keep mute in sync with the Settings page / other tabs.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
||||
|
||||
const showBubble = useCallback((text: string, force: boolean) => {
|
||||
if (!text) return;
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
setBubbleState(b);
|
||||
bubbleTimer.current = setTimeout(() => {
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, ms);
|
||||
}, []);
|
||||
|
||||
const playMoment = useCallback((kind: PetMoment) => {
|
||||
clearTimeout(momentTimer.current);
|
||||
setMoment({ kind, key: ++momentKey.current });
|
||||
momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS);
|
||||
}, []);
|
||||
|
||||
const notify = useCallback(
|
||||
(pulse: NonNullable<SagiPulse>, subject?: SagiSubject) => {
|
||||
playMoment(MOMENT_OF_PULSE[pulse]);
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
const b = burst.current;
|
||||
// The same kind of news again, while its bubble is still up: count it in.
|
||||
if (b && b.pulse === pulse && t - b.at < COALESCE_MS) {
|
||||
b.count++;
|
||||
b.at = t;
|
||||
if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject);
|
||||
putBubble(burstBubble(b), SUBJECT_BUBBLE_MS);
|
||||
return;
|
||||
}
|
||||
const force = pulse === "error" || pulse === "waiting";
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t };
|
||||
burst.current = next;
|
||||
putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS);
|
||||
},
|
||||
[playMoment, putBubble]
|
||||
);
|
||||
|
||||
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||
// the brain otherwise only learns about sessions from WS deltas that arrive
|
||||
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
||||
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||
// Then say hello, once per browser session.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let greetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const greet = () => {
|
||||
try {
|
||||
if (sessionStorage.getItem(GREETED_KEY)) return;
|
||||
sessionStorage.setItem(GREETED_KEY, "1");
|
||||
} catch {
|
||||
return; // No storage: rather silent than a hello on every page.
|
||||
}
|
||||
greetTimer = setTimeout(() => {
|
||||
if (cancelled || mutedRef.current) return;
|
||||
const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`);
|
||||
if (!hello) return;
|
||||
lastBubbleAt.current = Date.now();
|
||||
putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) });
|
||||
playMoment("wave");
|
||||
}, GREET_DELAY_MS);
|
||||
};
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => undefined)
|
||||
.finally(greet);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(greetTimer);
|
||||
};
|
||||
}, []);
|
||||
}, [update, putBubble, playMoment]);
|
||||
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
const t = Date.now();
|
||||
setState((prev) => {
|
||||
const { state: next, pulse } = reduceSagi(prev, msg, t);
|
||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
||||
return next;
|
||||
});
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
// resolution: keep the old state then, so a hook burst (several frames a
|
||||
// second) does not re-render the mascot on every frame.
|
||||
if (!onlyActivityMoved(prev, next)) update(() => next);
|
||||
if (pulse) notify(pulse, subject);
|
||||
});
|
||||
const unsubConn = eventBus.onConnection((connected) => {
|
||||
setState((prev) => ({ ...prev, connected }));
|
||||
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
|
||||
});
|
||||
return () => {
|
||||
unsubMsg();
|
||||
unsubConn();
|
||||
};
|
||||
}, [showBubble]);
|
||||
}, [update, notify]);
|
||||
|
||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
useEffect(() => {
|
||||
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
||||
// Only a tick that changes the mood re-renders: an idle mascot is not
|
||||
// drawn again every second for nothing.
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") setTick(Date.now());
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const now = Date.now();
|
||||
if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now);
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
clearTimeout(momentTimer.current);
|
||||
clearTimeout(reactionTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
||||
// Judged at render time: `tick` only moves when the mood would change, so
|
||||
// it may be old when a frame re-renders.
|
||||
const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]);
|
||||
const status = useMemo(() => statusOf(state), [state]);
|
||||
const attention = useMemo(() => attentionOf(state), [state]);
|
||||
|
||||
// An idle pet is not a statue: every minute or so it waves or looks around.
|
||||
const moodRef = useRef(mood);
|
||||
moodRef.current = mood;
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const schedule = () => {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
if (moodRef.current === "idle" && document.visibilityState === "visible")
|
||||
playMoment(Math.random() < 0.5 ? "wave" : "review");
|
||||
schedule();
|
||||
},
|
||||
FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS
|
||||
);
|
||||
};
|
||||
schedule();
|
||||
return () => clearTimeout(timer);
|
||||
}, [playMoment]);
|
||||
|
||||
const dismissBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
@@ -192,29 +372,47 @@ export function useSagiBrain(): SagiBrain {
|
||||
if (next) dismissBubble();
|
||||
}, [dismissBubble]);
|
||||
|
||||
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
|
||||
const clearAlerts = useCallback(() => {
|
||||
update((prev) => clearErrors(prev));
|
||||
if (burst.current?.pulse === "error") dismissBubble();
|
||||
}, [update, dismissBubble]);
|
||||
|
||||
const setThinking = useCallback(
|
||||
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[]
|
||||
(v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[update]
|
||||
);
|
||||
|
||||
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
|
||||
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
||||
// bypassing the throttled mood-driven showBubble path.
|
||||
const setBubble = useCallback((text: string) => {
|
||||
if (!text) return;
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
}, []);
|
||||
const setBubble = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) return;
|
||||
burst.current = null;
|
||||
putBubble({ text });
|
||||
},
|
||||
[putBubble]
|
||||
);
|
||||
|
||||
const pat = useCallback(() => {
|
||||
playMoment("jump");
|
||||
update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS }));
|
||||
clearTimeout(reactionTimer.current);
|
||||
setReaction({ text: pickLine("pat"), key: ++momentKey.current });
|
||||
reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS);
|
||||
}, [playMoment, update]);
|
||||
|
||||
return {
|
||||
mood,
|
||||
status,
|
||||
attention,
|
||||
bubble,
|
||||
dismissBubble,
|
||||
setBubble,
|
||||
moment,
|
||||
playMoment,
|
||||
pat,
|
||||
reaction,
|
||||
muted,
|
||||
toggleMute,
|
||||
clearAlerts,
|
||||
|
||||
@@ -56,6 +56,8 @@ import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
Gem,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Wifi,
|
||||
@@ -88,6 +90,10 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
const NAV_KEYS = [
|
||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||
{ 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";
|
||||
@@ -194,7 +200,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);
|
||||
@@ -469,7 +476,8 @@ 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,
|
||||
// The file manager serves a shared server; locally it is the desktop's.
|
||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
@@ -552,7 +560,7 @@ export function Sidebar({
|
||||
type="button"
|
||||
onClick={() => void auth.logout()}
|
||||
title={`${t("common:auth.signedInAs", {
|
||||
name: auth.state.user.username,
|
||||
name: auth.state.user.displayName || auth.state.user.username,
|
||||
role: auth.state.user.role,
|
||||
})} — ${t("common:auth.logout")}`}
|
||||
aria-label={t("common:auth.logout")}
|
||||
|
||||
@@ -10,7 +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_name`; yours
|
||||
* analyst: EVERY workspace, grouped under its owner (`owner_display_name`,
|
||||
* else `owner_name`; yours
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
@@ -60,6 +61,9 @@ const DestructiveLaneModal = lazy(() =>
|
||||
|
||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
||||
/** An owner as the tree shows it: their display name, else their username. */
|
||||
const ownerShown = (l: Lane): string => l.owner_display_name || l.owner_name || "";
|
||||
|
||||
export function selectedFrom(pathname: string, search: string) {
|
||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||
const p = new URLSearchParams(search);
|
||||
@@ -150,7 +154,7 @@ export function WorkspaceTree() {
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
|
||||
return [...list].sort(
|
||||
(a, b) => rank(a) - rank(b) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
|
||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||
);
|
||||
}, [lanes, order, toolFilter, grouped, meId]);
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -338,8 +342,8 @@ export function WorkspaceTree() {
|
||||
lane.owner_id == null
|
||||
? t("tree.noOwner")
|
||||
: lane.owner_id === meId
|
||||
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
|
||||
: (lane.owner_name ?? `#${lane.owner_id}`);
|
||||
? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` })
|
||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
return (
|
||||
@@ -366,7 +370,7 @@ export function WorkspaceTree() {
|
||||
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"
|
||||
>
|
||||
{lane.owner_name?.[0] ?? "?"}
|
||||
{ownerShown(lane)[0] ?? "?"}
|
||||
</span>
|
||||
<span className="truncate">{ownerLabel}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description Unit tests for the Browse button beside path fields: a chosen
|
||||
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
||||
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
||||
* EREMOTE included).
|
||||
* EREMOTE included), where a double-click on a file picks it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ import { toast } from "../../lib/toast";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } },
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
@@ -55,7 +55,7 @@ describe("PathPickButton", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the in-app browser for a file too, and a click on a file picks it", async () => {
|
||||
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
|
||||
const onPick = vi.fn();
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
@@ -68,9 +68,9 @@ describe("PathPickButton", () => {
|
||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true);
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
|
||||
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||
await userEvent.click(await within(dialog).findByText("id_ed25519"));
|
||||
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
||||
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
|
||||
}) as unknown as Lane;
|
||||
|
||||
const lanes = [
|
||||
lane(1, 3, "bob", "Bob app"),
|
||||
{ ...lane(1, 3, "bob", "Bob app"), owner_display_name: "Bob Nguyen" } as Lane,
|
||||
lane(2, null, null, "Old"),
|
||||
lane(3, ME.id, "ana", "Mine"),
|
||||
lane(4, 1, "admin", "Admin site"),
|
||||
@@ -113,6 +113,14 @@ describe("WorkspaceTree for an analyst", () => {
|
||||
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
||||
});
|
||||
|
||||
it("names an owner by their display name when they set one", async () => {
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
|
||||
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
|
||||
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
|
||||
});
|
||||
|
||||
it("folds a group and remembers it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
|
||||
@@ -154,7 +154,12 @@ describe("WorkspaceTree", () => {
|
||||
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
||||
// Its OWN sessions only — never the rest of its folder.
|
||||
expect(api.sessions.list).toHaveBeenCalledTimes(1);
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 6,
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
||||
"1": true,
|
||||
});
|
||||
@@ -175,11 +180,21 @@ describe("WorkspaceTree", () => {
|
||||
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
|
||||
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
|
||||
await user.click(screen.getByTestId("workspace-sessions-more-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 9,
|
||||
});
|
||||
await screen.findByText("Session 8");
|
||||
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
||||
await user.click(screen.getByTestId("workspace-sessions-less-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 6,
|
||||
});
|
||||
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
||||
});
|
||||
@@ -197,6 +212,38 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
it("pins a session above the live ones, and unpins it from its row or menu", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [
|
||||
session("s-live", "Live one"),
|
||||
{ ...session("s-old", "Old one"), pinned_at: "2026-10-01T00:00:00Z" } as Session,
|
||||
],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const list = await screen.findByTestId("workspace-sessions-1");
|
||||
await screen.findByTestId("workspace-session-pinned-s-old");
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByTestId(/^workspace-session-s-/)
|
||||
.map((el) => el.dataset.testid)
|
||||
).toEqual(["workspace-session-s-old", "workspace-session-s-live"]);
|
||||
await user.click(screen.getByTestId("workspace-session-pin-s-old"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { pinned: false });
|
||||
await user.click(screen.getByTestId("workspace-session-pin-s-live"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { pinned: true });
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: screen.getByTestId("workspace-session-s-old"),
|
||||
});
|
||||
await user.click(within(screen.getByTestId("session-menu-s-old")).getByText("Unpin"));
|
||||
expect(api.sessions.patch).toHaveBeenLastCalledWith("s-old", { pinned: false });
|
||||
});
|
||||
|
||||
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
|
||||
@@ -61,13 +61,15 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { memo, useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
|
||||
@@ -80,6 +82,10 @@ const POLL_INTERVAL_MS = 3000;
|
||||
// Rescan the transcripts list periodically so new subagents that spawn
|
||||
// mid-session appear in the dropdown without a page reload.
|
||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
// A hook burst (Pre + Post per tool call, several sessions) refetches the tail
|
||||
// at most this often: the first event still fetches at once, the last is never
|
||||
// lost, and a long transcript is not re-read for every single frame.
|
||||
const EVENT_FETCH_MS = 1500;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
@@ -106,7 +112,7 @@ interface ConversationViewProps {
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConversationView({
|
||||
function ConversationViewPane({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
@@ -161,6 +167,10 @@ export function ConversationView({
|
||||
// Bumped whenever the session or the selected transcript changes: a tail
|
||||
// fetch started for the previous transcript must not append to the new one.
|
||||
const genRef = useRef(0);
|
||||
// A pane of another workspace or tab stays mounted but hidden: it neither
|
||||
// polls nor follows events, and catches up once when it is shown again.
|
||||
const onShowRef = useRef<() => void>(() => {});
|
||||
const onScreen = useOnScreen(scrollContainerRef, () => onShowRef.current());
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
|
||||
@@ -179,7 +189,7 @@ export function ConversationView({
|
||||
}
|
||||
loadTranscripts();
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") loadTranscripts();
|
||||
if (isShown(onScreen)) loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -299,24 +309,33 @@ export function ConversationView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, selectedTranscript]);
|
||||
|
||||
// WebSocket subscription: refetch on every new_event for this session.
|
||||
// WebSocket subscription: refetch on new_event for this session, throttled.
|
||||
// Hook coverage isn't complete (a user-typed message fires no hook), so we
|
||||
// also poll below to catch what WS misses.
|
||||
const fetchSoon = useMemo(
|
||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab: the visibility handler below catches up once on show.
|
||||
if (document.visibilityState !== "visible") return;
|
||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||
if (!isShown(onScreen)) return;
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
fetchNewMessages();
|
||||
fetchSoon();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchNewMessages();
|
||||
fetchSoon();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchNewMessages]);
|
||||
}, [sessionId, fetchSoon, onScreen]);
|
||||
|
||||
onShowRef.current = () => {
|
||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||
};
|
||||
|
||||
// Resync on WebSocket reconnect: events that landed during a transient
|
||||
// disconnect are gone from the bus, but the JSONL still has them, so a
|
||||
@@ -337,7 +356,7 @@ export function ConversationView({
|
||||
function start() {
|
||||
if (interval !== null) return;
|
||||
interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") fetchNewMessages();
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
function stop() {
|
||||
@@ -363,7 +382,7 @@ export function ConversationView({
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchNewMessages]);
|
||||
}, [fetchNewMessages, onScreen]);
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
@@ -627,3 +646,7 @@ export function ConversationView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Memoized: a chat's prompt box lives in its parent (PaneChat), and every
|
||||
* keystroke there re-rendered the whole conversation. */
|
||||
export const ConversationView = memo(ConversationViewPane);
|
||||
|
||||
@@ -4,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.
|
||||
@@ -256,7 +258,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 +298,17 @@ 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 +317,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 +332,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++];
|
||||
@@ -359,7 +384,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
</code>
|
||||
);
|
||||
push(
|
||||
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
||||
matchPath(codeM[1]!) === codeM[1] ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
@@ -408,9 +433,23 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
// Markdown link
|
||||
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
|
||||
if (linkM) {
|
||||
const href = linkM[2]!;
|
||||
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
|
||||
if (
|
||||
/^file:\/\//i.test(href) ||
|
||||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
||||
) {
|
||||
push(
|
||||
<PathLink path={decodeHref(href)}>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
</PathLink>
|
||||
);
|
||||
i += linkM[0].length;
|
||||
continue;
|
||||
}
|
||||
push(
|
||||
<a
|
||||
href={linkM[2]!}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
@@ -431,10 +470,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,17 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
|
||||
return map;
|
||||
}
|
||||
|
||||
type ResultMap = ReturnType<typeof buildToolResultMap>;
|
||||
|
||||
/** The map is rebuilt on every tail append, but a block's own result keeps its
|
||||
* identity (old messages are kept, not copied): comparing just those lets a
|
||||
* memoized row skip the re-render when nothing it shows changed. */
|
||||
const sameResults = (blocks: TranscriptContent[], a: ResultMap, b: ResultMap) =>
|
||||
a === b || blocks.every((bl) => !bl.id || a.get(bl.id) === b.get(bl.id));
|
||||
|
||||
const sameBlocks = (a: TranscriptContent[], b: TranscriptContent[]) =>
|
||||
a === b || (a.length === b.length && a.every((bl, i) => bl === b[i]));
|
||||
|
||||
/** The whole prompt a message was answered from: `input_tokens` is only its
|
||||
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
|
||||
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||
@@ -544,7 +555,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
|
||||
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
|
||||
* the lines added and removed. A call still running or failed is left out. */
|
||||
function EditedFiles({
|
||||
function EditedFilesCard({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
onOpenFile,
|
||||
@@ -614,8 +625,15 @@ function EditedFiles({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const EditedFiles = memo(
|
||||
EditedFilesCard,
|
||||
(p, n) =>
|
||||
p.onOpenFile === n.onOpenFile &&
|
||||
sameBlocks(p.blocks, n.blocks) &&
|
||||
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
|
||||
);
|
||||
|
||||
export function MessageList({
|
||||
function MessageListView({
|
||||
messages,
|
||||
loading,
|
||||
onOpenFile,
|
||||
@@ -870,8 +888,16 @@ function RunningStep({
|
||||
const { t } = useTranslation("sessions");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
// A hidden tab or a hidden pane (another workspace, kept mounted) shows
|
||||
// no clock: skip the re-render until it is on screen again.
|
||||
const timer = window.setInterval(() => {
|
||||
const el = boxRef.current;
|
||||
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
|
||||
return;
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const counted = useCountUp(tokens);
|
||||
@@ -893,7 +919,7 @@ function RunningStep({
|
||||
].filter(Boolean);
|
||||
const amber = waiting || quiet;
|
||||
return (
|
||||
<div data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
|
||||
<div ref={boxRef} data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{waiting ? (
|
||||
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
|
||||
@@ -951,9 +977,16 @@ function RunningStep({
|
||||
}
|
||||
|
||||
/** Under a turn that ended: how long it took and the output it made. */
|
||||
/** An option's label carries the model's " (Recommended)" marker into the
|
||||
* answer the CLI records; the user's pick reads without it (every picked
|
||||
* label of a multi-select answer too). */
|
||||
function stripRecommended(answer: string | undefined): string | undefined {
|
||||
return answer?.replace(/\s*\(recommended\)/gi, "").trim();
|
||||
}
|
||||
|
||||
/** An AskUserQuestion: each question with the answer the user picked or
|
||||
* typed (the result's `answers`), "declined" when they refused it, no answer
|
||||
* yet while the CLI still asks. */
|
||||
* typed (the result's `answers`, minus the " (Recommended)" marker),
|
||||
* "declined" when they refused it, no answer yet while the CLI still asks. */
|
||||
function AskCard({
|
||||
block,
|
||||
result,
|
||||
@@ -972,7 +1005,7 @@ function AskCard({
|
||||
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
{questions.map((q, i) => {
|
||||
const answer = result?.answers?.[q];
|
||||
const answer = stripRecommended(result?.answers?.[q]);
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-start gap-1.5 text-fg-secondary">
|
||||
@@ -1362,7 +1395,7 @@ function WorkStep({
|
||||
|
||||
/** What the assistant did between two replies as one line, opening onto
|
||||
* one framed list of its steps. */
|
||||
const WorkGroup = memo(function WorkGroup({
|
||||
function WorkGroupLine({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
}: {
|
||||
@@ -1384,11 +1417,16 @@ const WorkGroup = memo(function WorkGroup({
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
const WorkGroup = memo(
|
||||
WorkGroupLine,
|
||||
(p, n) =>
|
||||
sameBlocks(p.blocks, n.blocks) && sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
|
||||
);
|
||||
|
||||
/** One user/assistant row. Memoized: a live tail append re-renders only the
|
||||
* new rows; its thinking-block expansion is its own state, kept by key. */
|
||||
const MessageRow = memo(function MessageRow({
|
||||
function MessageRowView({
|
||||
msg,
|
||||
toolResultMap,
|
||||
}: {
|
||||
@@ -1507,4 +1545,11 @@ const MessageRow = memo(function MessageRow({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
const MessageRow = memo(
|
||||
MessageRowView,
|
||||
(p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap)
|
||||
);
|
||||
|
||||
/** Memoized: its owner re-renders on scroll state and its parent's typing. */
|
||||
export const MessageList = memo(MessageListView);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @file ConversationView.hidden.test.tsx
|
||||
* @description A chat kept mounted in a hidden pane (another workspace or
|
||||
* tab, `display:none`) does not follow the live stream: a new_event for its
|
||||
* session fetches nothing while its box is off screen, and showing it again
|
||||
* catches the tail up once. On screen, a burst of events is throttled to one
|
||||
* fetch at once plus one after the burst.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
import { api } from "../../../lib/api";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let intersect: ((visible: boolean) => void) | null = null;
|
||||
const original = globalThis.IntersectionObserver;
|
||||
beforeEach(() => {
|
||||
intersect = null;
|
||||
// @ts-expect-error test stub
|
||||
globalThis.IntersectionObserver = class {
|
||||
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
|
||||
intersect = (visible) => cb([{ isIntersecting: visible }]);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.IntersectionObserver = original;
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const event = () =>
|
||||
act(() => eventBus.publish({ type: "new_event", data: { session_id: "s1" } } as never));
|
||||
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||
|
||||
describe("ConversationView in a hidden pane", () => {
|
||||
it("fetches nothing while hidden and catches up once when shown", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const before = calls();
|
||||
|
||||
act(() => intersect?.(false));
|
||||
event();
|
||||
event();
|
||||
expect(calls()).toBe(before);
|
||||
|
||||
act(() => intersect?.(true));
|
||||
await act(async () => {});
|
||||
expect(calls()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("throttles a burst of events on screen", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
vi.useFakeTimers();
|
||||
const before = calls();
|
||||
event();
|
||||
event();
|
||||
event();
|
||||
// The first fetches at once; the rest wait for the throttle window.
|
||||
expect(calls()).toBe(before + 1);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1600);
|
||||
});
|
||||
expect(calls()).toBe(before + 2);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file MessageList.ask.test.tsx
|
||||
* @description An AskUserQuestion call is not folded into the collapsed work
|
||||
* line: the chat shows it as its own card — each question with the answer the
|
||||
* user picked or typed, "declined" when they refused it, nothing yet while the
|
||||
* user picked or typed (without the option's " (Recommended)" marker), "declined" when they refused it, nothing yet while the
|
||||
* CLI still waits.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -67,6 +67,25 @@ describe("an AskUserQuestion card", () => {
|
||||
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the (Recommended) marker from a picked answer", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("go"),
|
||||
ask("q1", ["Commit?", "Tools?"]),
|
||||
result("q1", {
|
||||
answers: { "Commit?": "Yes (Recommended)", "Tools?": "Lint (Recommended), Test" },
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Yes")).toBeTruthy();
|
||||
expect(within(card).getByText("Lint, Test")).toBeTruthy();
|
||||
expect(within(card).queryByText(/Recommended/)).toBeNull();
|
||||
});
|
||||
|
||||
it("says declined when refused, and shows no answer while still asked", () => {
|
||||
const { rerender } = render(
|
||||
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* @file AddLaneModal.tsx
|
||||
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||||
* @description The "+ Add lane" flow, in one of three modes chosen with a
|
||||
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
|
||||
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
|
||||
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||
* main repo you want stage detection on); "Worktree" provisions a
|
||||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||
* manually-typed branch name. The user also picks which CLI drives the lane
|
||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git). There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
|
||||
@@ -21,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";
|
||||
@@ -29,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
|
||||
@@ -48,6 +51,17 @@ async function waitForProvisioned(
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
|
||||
export function cloneDirName(url: string): string {
|
||||
return (
|
||||
url
|
||||
.trim()
|
||||
.replace(/[/:]+$/, "")
|
||||
.split(/[/:]/)
|
||||
.pop() || ""
|
||||
).replace(/\.git$/i, "");
|
||||
}
|
||||
|
||||
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||||
function Seg({
|
||||
active,
|
||||
@@ -97,7 +111,18 @@ export function AddLaneModal({
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("repo");
|
||||
const [mode, setMode] = useState<"repo" | "worktree" | "clone">("repo");
|
||||
// Clone mode: the URL, the folder name (blank = the URL's), and — once the
|
||||
// server asked for one — a login for the URL's https host.
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
token: string;
|
||||
remember: boolean;
|
||||
} | null>(null);
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
@@ -110,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);
|
||||
@@ -141,6 +157,10 @@ export function AddLaneModal({
|
||||
|
||||
const reset = () => {
|
||||
setMode("repo");
|
||||
setCloneUrl("");
|
||||
setCloneName("");
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setSourceRepo("");
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
@@ -148,7 +168,6 @@ export function AddLaneModal({
|
||||
setWorktrees([]);
|
||||
setBranchMode("new");
|
||||
setBase("");
|
||||
setPickedTool(null);
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
@@ -213,6 +232,52 @@ export function AddLaneModal({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
if (mode === "clone") {
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || url;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
token: "",
|
||||
remember: l?.remember ?? true,
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "repo") {
|
||||
try {
|
||||
const result = await api.lanes.ensure({
|
||||
@@ -328,7 +393,6 @@ export function AddLaneModal({
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const browseRef = useRef<HTMLButtonElement>(null);
|
||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
@@ -338,6 +402,8 @@ export function AddLaneModal({
|
||||
const disabled =
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "clone" &&
|
||||
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
@@ -394,81 +460,58 @@ export function AddLaneModal({
|
||||
title={t("mode.worktreeHint")}
|
||||
onClick={() => setMode("worktree")}
|
||||
/>
|
||||
<Seg
|
||||
active={mode === "clone"}
|
||||
label={t("mode.clone")}
|
||||
title={t("mode.cloneHint")}
|
||||
onClick={() => setMode("clone")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
||||
{noTools ? (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={toolListOpen}
|
||||
disabled={toolChoices.length < 2}
|
||||
onClick={() => setToolListOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
|
||||
>
|
||||
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
|
||||
{tool === defaultTool && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("addLaneToolDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{toolChoices.length > 1 && (
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{toolListOpen && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("addLaneToolHint")}
|
||||
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||
>
|
||||
{toolChoices.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
aria-pressed={x.id === tool}
|
||||
onClick={() => pickTool(x.id)}
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
x.id === tool
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mode === "clone" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-url">
|
||||
{t("clone.urlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-url"
|
||||
value={cloneUrl}
|
||||
onChange={(e) => {
|
||||
setCloneUrl(e.target.value);
|
||||
// A login belongs to the host it was asked for.
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo.git"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noTools && (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
{mode === "repo"
|
||||
? t("addLaneRepoLabelAdopt")
|
||||
: mode === "clone"
|
||||
? t("clone.parentLabel")
|
||||
: t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||
{/* A click into the path opens the picker, as Browse does (the
|
||||
OS's own where it can be); typing still works from the keyboard. */}
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{/* A click into the path only focuses it — the picker opens from
|
||||
Browse alone (#77). */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
@@ -476,18 +519,70 @@ export function AddLaneModal({
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<PathPickButton
|
||||
start={sourceRepo}
|
||||
onPick={setSourceRepo}
|
||||
showLabel
|
||||
buttonRef={browseRef}
|
||||
/>
|
||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
{mode === "clone"
|
||||
? t("clone.parentHint", {
|
||||
path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${
|
||||
cloneName.trim() || cloneDirName(cloneUrl) || "…"
|
||||
}`,
|
||||
})
|
||||
: mode === "repo"
|
||||
? t("addLaneRepoHintAdopt")
|
||||
: t("addLaneRepoHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === "clone" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-clone-name">
|
||||
{t("clone.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-clone-name"
|
||||
value={cloneName}
|
||||
onChange={(e) => setCloneName(e.target.value)}
|
||||
placeholder={cloneDirName(cloneUrl)}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "clone" && login && (
|
||||
<div
|
||||
data-testid="add-lane-clone-login"
|
||||
className="grid gap-2 rounded-md border border-border-light bg-surface-0 p-2 sm:grid-cols-2"
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="off"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="new-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What this repo already has checked out. Without it the form asked
|
||||
for a branch in the dark: the one fact that decides whether a
|
||||
branch can be used at all is which worktree is holding it. */}
|
||||
@@ -701,6 +796,14 @@ export function AddLaneModal({
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
{sshRefused && (
|
||||
<>
|
||||
{" "}
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -721,7 +824,11 @@ export function AddLaneModal({
|
||||
data-tour="add-lane-create"
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{done ? t("addLaneSetupClose") : t("add")}
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
|
||||
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
|
||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||
* added as an extra folder, one after another. A host that wants a login
|
||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||
* URLs with it, like Add workspace → Clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
|
||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
export function CloneSourcesForm({
|
||||
laneId,
|
||||
parent,
|
||||
onAdded,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Where the clones land: the workspace folder's parent, shown only. */
|
||||
parent: string;
|
||||
onAdded: (view: LaneSourcesView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [text, setText] = useState("");
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
|
||||
const urls = (s: string) => [
|
||||
...new Set(
|
||||
s
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
setBusy(true);
|
||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||
const show = () => setRows([...next]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
for (const row of next) {
|
||||
row.state = "busy";
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
row.state = "failed";
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
asked ??= {
|
||||
host,
|
||||
username: login?.username ?? "",
|
||||
token: "",
|
||||
remember: login?.remember ?? true,
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
// What is left to do stays in the box; what was added leaves it.
|
||||
setText(
|
||||
next
|
||||
.filter((r) => r.state !== "done")
|
||||
.map((r) => r.url)
|
||||
.join("\n")
|
||||
);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||
<textarea
|
||||
data-testid="workspace-clone-input"
|
||||
aria-label={t("home.cloneFolders")}
|
||||
placeholder={t("home.clonePlaceholder")}
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
{login && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
{failed.length > 0 && !busy && (
|
||||
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
|
||||
{t("home.cloneRetry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-clone-run"
|
||||
disabled={busy || !urls(text).length}
|
||||
onClick={() => void cloneAll(urls(text))}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("home.cloneRun")}
|
||||
</button>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<ul className="space-y-1" data-testid="workspace-clone-rows">
|
||||
{rows.map((r) => (
|
||||
<li key={r.url} className="text-[11px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{r.state === "busy" ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
|
||||
) : r.state === "done" ? (
|
||||
<Check
|
||||
className="h-3 w-3 shrink-0 text-status-success"
|
||||
aria-label={t("home.cloneDone")}
|
||||
/>
|
||||
) : r.state === "failed" ? (
|
||||
<X className="h-3 w-3 shrink-0 text-status-danger" />
|
||||
) : (
|
||||
<span className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,102 @@
|
||||
/**
|
||||
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
||||
* button. Browsers refuse to expose an absolute filesystem path from a native
|
||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||
* (immediate subdirectories of a path, plus its files for `kind="file"`)
|
||||
* instead — breadcrumb-free, just an up-one-level button and a
|
||||
* click-to-descend list; in file mode a click on a file picks it.
|
||||
* button, laid out like the OS's own picker. Browsers refuse to expose an
|
||||
* absolute filesystem path from a native picker, so path selection here is
|
||||
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
|
||||
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
|
||||
* (Home, the user's workspaces, recent picks), a filter, Name/Modified columns,
|
||||
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
|
||||
* A click selects, a double-click or Enter opens a folder (picks a file),
|
||||
* Backspace goes up and typing jumps to a name.
|
||||
* An admin with a Linux account of their own (S3; the listing carries
|
||||
* `hostHome`) opens in THEIR home; the "Server folder" tick switches the
|
||||
* browser to the host account's home, where a workspace runs as the shared
|
||||
* host account. It is never remembered: every open starts at the admin's own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FolderGit2, ArrowUp, File } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Clock,
|
||||
Eye,
|
||||
File,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Pencil,
|
||||
Server,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useWorkLanes } from "../../hooks/useLanes";
|
||||
|
||||
export function FolderBrowseModal({
|
||||
type Listing = Awaited<ReturnType<typeof api.lanes.browse>>;
|
||||
type Entry = Listing["entries"][number];
|
||||
|
||||
const RECENT_KEY = "agenthub.browse.recent";
|
||||
const HIDDEN_KEY = "agenthub.browse.hidden";
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
function readStored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// A private window or blocked storage: the preference just isn't kept.
|
||||
}
|
||||
}
|
||||
|
||||
/** `[label, path]` for each level of `p`, root first. */
|
||||
function crumbs(p: string): [string, string][] {
|
||||
const win = /^[A-Za-z]:\\/.test(p);
|
||||
const sep = win ? "\\" : "/";
|
||||
const parts = p.split(sep).filter(Boolean);
|
||||
const out: [string, string][] = win ? [] : [["/", "/"]];
|
||||
let acc = "";
|
||||
for (const part of parts) {
|
||||
acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`;
|
||||
out.push([part, acc]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p;
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
/** `file` lists files too, and picking one is the only way to select. */
|
||||
kind?: "dir" | "file";
|
||||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||
initialPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mounted only while open, so a closed picker (one behind every Browse
|
||||
* button) asks the server for nothing, and each opening starts fresh.
|
||||
*/
|
||||
export function FolderBrowseModal(props: Props) {
|
||||
return props.open ? <BrowseDialog {...props} /> : null;
|
||||
}
|
||||
|
||||
function BrowseDialog({
|
||||
open,
|
||||
kind = "dir",
|
||||
initialPath,
|
||||
@@ -30,20 +112,67 @@ export function FolderBrowseModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
||||
const { lanes } = useWorkLanes();
|
||||
const [listing, setListing] = useState<Listing | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [pathText, setPathText] = useState("");
|
||||
const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({
|
||||
key: "name",
|
||||
dir: 1,
|
||||
});
|
||||
const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false));
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [recent, setRecent] = useState<string[]>(() => readStored(RECENT_KEY, []));
|
||||
// The highest folder this user may open (a member's own folder answers no
|
||||
// parent) — breadcrumbs above it are not links.
|
||||
const [floor, setFloor] = useState<string | null>(null);
|
||||
const [home, setHome] = useState<string | null>(null);
|
||||
// An admin's "Server folder" tick (S3): browse the host account's home.
|
||||
const [hostHome, setHostHome] = useState<string | null>(null);
|
||||
const [onHost, setOnHost] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const crumbRef = useRef<HTMLDivElement>(null);
|
||||
const typeAhead = useRef({ text: "", at: 0 });
|
||||
|
||||
const load = async (path?: string) => {
|
||||
const load = async (
|
||||
target?: string,
|
||||
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
setListing(await (kind === "file" ? api.lanes.browse(path, true) : api.lanes.browse(path)));
|
||||
const next = await api.lanes.browse(target, kind === "file", hidden, host);
|
||||
setListing(next);
|
||||
setSelected(select);
|
||||
setFilter("");
|
||||
setEditing(false);
|
||||
if (next.home) setHome(next.home);
|
||||
setHostHome(next.hostHome ?? null);
|
||||
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||||
if (push) {
|
||||
setHistory((h) => {
|
||||
if (h.stack[h.at] === next.path) return h;
|
||||
const stack = [...h.stack.slice(0, h.at + 1), next.path];
|
||||
return { stack, at: stack.length - 1 };
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load(initialPath);
|
||||
if (!open) return;
|
||||
setHistory({ stack: [], at: -1 });
|
||||
setCreating(false);
|
||||
setOnHost(false);
|
||||
void load(initialPath, { host: false });
|
||||
// Only re-run when the modal actually opens - not on every initialPath
|
||||
// keystroke in the field behind it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -54,8 +183,171 @@ export function FolderBrowseModal({
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, panel, onClose);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase();
|
||||
const list = (listing?.entries ?? []).filter(
|
||||
(e) => !needle || e.name.toLowerCase().includes(needle)
|
||||
);
|
||||
const by = (a: Entry, b: Entry) =>
|
||||
sort.key === "mtime"
|
||||
? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir
|
||||
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) *
|
||||
sort.dir;
|
||||
// Folders first, as every file picker lists them.
|
||||
return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b));
|
||||
}, [listing, filter, sort]);
|
||||
|
||||
const places = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return lanes
|
||||
.filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd))
|
||||
.map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd }));
|
||||
}, [lanes]);
|
||||
|
||||
// A deep path keeps its LAST levels in view, like a native path bar.
|
||||
useEffect(() => {
|
||||
const el = crumbRef.current;
|
||||
if (el) el.scrollLeft = el.scrollWidth;
|
||||
}, [listing?.path, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const el = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-path]") ?? []).find(
|
||||
(x) => x.dataset.path === selected
|
||||
);
|
||||
el?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [selected]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const finish = (picked: string) => {
|
||||
const folder = kind === "file" ? (listing?.path ?? picked) : picked;
|
||||
const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX);
|
||||
setRecent(next);
|
||||
writeStored(RECENT_KEY, next);
|
||||
onSelect(picked);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const activate = (entry: Entry) => {
|
||||
if (entry.file) finish(entry.path);
|
||||
else void load(entry.path);
|
||||
};
|
||||
|
||||
const go = (delta: -1 | 1) => {
|
||||
const at = history.at + delta;
|
||||
const target = history.stack[at];
|
||||
if (target === undefined) return;
|
||||
void load(target, { push: false }).then((ok) => {
|
||||
if (ok) setHistory((h) => ({ ...h, at }));
|
||||
});
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
if (!listing?.parent) return;
|
||||
const from = listing.path;
|
||||
void load(listing.parent, { select: from });
|
||||
};
|
||||
|
||||
const selectedEntry = entries.find((e) => e.path === selected) ?? null;
|
||||
const target =
|
||||
kind === "file"
|
||||
? selectedEntry?.file
|
||||
? selectedEntry.path
|
||||
: null
|
||||
: selectedEntry && !selectedEntry.file
|
||||
? selectedEntry.path
|
||||
: (listing?.path ?? null);
|
||||
|
||||
const onListKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const idx = entries.findIndex((x) => x.path === selected);
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (e.altKey && e.key === "ArrowUp") return goUp();
|
||||
const next =
|
||||
e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1);
|
||||
if (entries[next]) setSelected(entries[next].path);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1];
|
||||
if (pick) setSelected(pick.path);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (selectedEntry) activate(selectedEntry);
|
||||
else if (kind === "dir" && target) finish(target);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
goUp();
|
||||
return;
|
||||
}
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Typing jumps to the first name that starts with what was typed.
|
||||
const now = Date.now();
|
||||
const ta = typeAhead.current;
|
||||
ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
|
||||
ta.at = now;
|
||||
const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text));
|
||||
if (hit) setSelected(hit.path);
|
||||
}
|
||||
};
|
||||
|
||||
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
|
||||
e.preventDefault();
|
||||
setPathText(listing?.path ?? "");
|
||||
setEditing(true);
|
||||
} else if (e.altKey && e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
go(-1);
|
||||
} else if (e.altKey && e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
go(1);
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || !listing) return;
|
||||
setError(null);
|
||||
try {
|
||||
const made = await api.lanes.mkdir(listing.path, name);
|
||||
setCreating(false);
|
||||
setNewName("");
|
||||
await load(listing.path, { push: false, select: made.path });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Switch between the admin's own home and the host account's, and open it.
|
||||
const toggleHost = () => {
|
||||
const next = !onHost;
|
||||
setOnHost(next);
|
||||
void load(undefined, { host: next });
|
||||
};
|
||||
|
||||
const toggleHidden = () => {
|
||||
const next = !showHidden;
|
||||
setShowHidden(next);
|
||||
writeStored(HIDDEN_KEY, next);
|
||||
if (listing) void load(listing.path, { push: false, hidden: next, select: selected });
|
||||
};
|
||||
|
||||
const iconBtn =
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
const placeBtn = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${
|
||||
active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||||
}`;
|
||||
const path = listing?.path ?? "";
|
||||
const floorDepth = floor ? crumbs(floor).length : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@@ -64,82 +356,362 @@ export function FolderBrowseModal({
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={onPanelKey}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||||
data-tour="folder-browse"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||
{listing?.path || "…"}
|
||||
{/* Toolbar: history, up, and the breadcrumb / path field. */}
|
||||
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={() => go(-1)}
|
||||
disabled={history.at <= 0}
|
||||
title={t("browse.back")}
|
||||
aria-label={t("browse.back")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={() => go(1)}
|
||||
disabled={history.at >= history.stack.length - 1}
|
||||
title={t("browse.forward")}
|
||||
aria-label={t("browse.forward")}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={goUp}
|
||||
disabled={!listing?.parent}
|
||||
title={t("browse.up")}
|
||||
aria-label={t("browse.up")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={pathText}
|
||||
onChange={(e) => setPathText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
if (pathText.trim()) void load(pathText.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditing(false)}
|
||||
aria-label={t("browse.editPath")}
|
||||
spellCheck={false}
|
||||
className="input min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={crumbRef}
|
||||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
|
||||
onDoubleClick={() => {
|
||||
setPathText(path);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{crumbs(path).map(([label, p], i, all) => {
|
||||
const last = i === all.length - 1;
|
||||
const locked = i + 1 < floorDepth;
|
||||
return (
|
||||
<span key={p} className="flex shrink-0 items-center">
|
||||
{i > 0 && all[i - 1]?.[0] !== "/" && (
|
||||
<span className="px-0.5 text-fg-muted">›</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={last || locked}
|
||||
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
|
||||
className={`rounded px-1 py-0.5 font-mono text-xs ${
|
||||
last
|
||||
? "font-medium text-fg-primary"
|
||||
: locked
|
||||
? "text-fg-muted"
|
||||
: "text-fg-secondary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setPathText(path);
|
||||
setEditing((v) => !v);
|
||||
}}
|
||||
title={t("browse.editPath")}
|
||||
aria-label={t("browse.editPath")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Places */}
|
||||
<nav
|
||||
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
|
||||
aria-label={t("browse.places")}
|
||||
>
|
||||
{home && (
|
||||
<button
|
||||
type="button"
|
||||
className={placeBtn(path === home)}
|
||||
onClick={() => void load(home)}
|
||||
title={home}
|
||||
>
|
||||
<Home className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{t("browse.home")}</span>
|
||||
</button>
|
||||
)}
|
||||
{places.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
<Star className="h-3 w-3" />
|
||||
{t("browse.workspaces")}
|
||||
</div>
|
||||
{places.map((pl) => (
|
||||
<button
|
||||
key={pl.path}
|
||||
type="button"
|
||||
className={placeBtn(path === pl.path)}
|
||||
onClick={() => void load(pl.path)}
|
||||
title={pl.path}
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate">{pl.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{recent.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("browse.recent")}
|
||||
</div>
|
||||
{recent.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={placeBtn(path === r)}
|
||||
onClick={() => void load(r)}
|
||||
title={r}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{baseName(r)}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Listing */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border p-2">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (entries[0]) setSelected(entries[0].path);
|
||||
listRef.current?.focus();
|
||||
} else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) {
|
||||
e.preventDefault();
|
||||
activate(entries[0]);
|
||||
}
|
||||
}}
|
||||
placeholder={t("browse.filter")}
|
||||
aria-label={t("browse.filter")}
|
||||
className="input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
{(["name", "mtime"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
|
||||
}
|
||||
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
|
||||
>
|
||||
{key === "name" ? t("browse.name") : t("browse.modified")}
|
||||
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
tabIndex={0}
|
||||
role="listbox"
|
||||
aria-label={path}
|
||||
onKeyDown={onListKey}
|
||||
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{creating && (
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void createFolder();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCreating(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("browse.newFolderName")}
|
||||
aria-label={t("browse.newFolderName")}
|
||||
className="input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createFolder()}
|
||||
disabled={!newName.trim()}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const isSel = entry.path === selected;
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
data-path={entry.path}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
onClick={() => setSelected(entry.path)}
|
||||
onDoubleClick={() => activate(entry)}
|
||||
className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${
|
||||
isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{entry.file ? (
|
||||
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
|
||||
) : entry.isGitRepo ? (
|
||||
<FolderGit2
|
||||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
|
||||
/>
|
||||
) : (
|
||||
<FolderOpen
|
||||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
<span
|
||||
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
|
||||
>
|
||||
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listing && entries.length === 0 && (
|
||||
<p className="p-2 text-xs text-fg-muted">
|
||||
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{listing?.parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(listing.parent!)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
..
|
||||
</button>
|
||||
)}
|
||||
{listing?.entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!entry.file) return void load(entry.path);
|
||||
onSelect(entry.path);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.file ? (
|
||||
<File className="h-3.5 w-3.5 text-fg-muted" />
|
||||
) : entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{listing && listing.entries.length === 0 && !listing.parent && (
|
||||
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
{/* Footer: what will be picked, and the actions. */}
|
||||
<div className="border-t border-border p-2">
|
||||
<div
|
||||
// rtl so a long path loses its START to the ellipsis, not the folder.
|
||||
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
|
||||
title={target ?? undefined}
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
{kind === "dir" && (
|
||||
<bdi>{target ?? "…"}</bdi>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
setNewName("");
|
||||
setCreating(true);
|
||||
}}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
disabled={!listing}
|
||||
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.select")}
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
{t("browse.newFolder")}
|
||||
</button>
|
||||
)}
|
||||
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
|
||||
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{t("browse.showHidden")}
|
||||
</label>
|
||||
{hostHome && (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
|
||||
title={t("browse.hostFolderHint", { path: hostHome })}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onHost}
|
||||
onChange={toggleHost}
|
||||
data-testid="browse-host-folder"
|
||||
/>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
{t("browse.hostFolder")}
|
||||
</label>
|
||||
)}
|
||||
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||||
{t("browse.hint")}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!target}
|
||||
onClick={() => target && finish(target)}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Download,
|
||||
Code2,
|
||||
Columns2,
|
||||
Eye,
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
UnfoldVertical,
|
||||
} 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";
|
||||
@@ -121,6 +123,7 @@ import {
|
||||
type LaneDiffChange,
|
||||
} from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
@@ -807,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). */
|
||||
@@ -852,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.
|
||||
@@ -867,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.
|
||||
@@ -939,6 +959,13 @@ export function LaneChangesPanel({
|
||||
const [message, setMessage] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// The last commit is already on its upstream: the server refuses to amend
|
||||
// it (EPUSHED), so the box is not offered — asking first and failing after
|
||||
// the confirm only costs the user a click.
|
||||
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
|
||||
useEffect(() => {
|
||||
if (headPushed) setAmend(false);
|
||||
}, [headPushed]);
|
||||
// The files the commit takes (JetBrains' checkboxes). A path listed for the
|
||||
// first time arrives with its default; `seenPaths` is the last full list,
|
||||
// so a poll never re-ticks a file the user unticked.
|
||||
@@ -1068,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
|
||||
@@ -1614,8 +1654,32 @@ export function LaneChangesPanel({
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
||||
{t("changes.showHistory")}
|
||||
</MenuItem>
|
||||
{!local && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-download"
|
||||
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.download")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!local && treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-upload"
|
||||
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.uploadHere")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{!local && treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-explorer"
|
||||
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
|
||||
>
|
||||
{t("changes.openInExplorer")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||||
{t("changes.copyPath")}
|
||||
</MenuItem>
|
||||
@@ -1861,6 +1925,61 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
// Files tab transfers (issue #78): a download is a plain attachment link; an
|
||||
// upload only creates, into the folder it was asked for ("" = the root).
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
const uploadDir = useRef("");
|
||||
const [, setUploading] = useState(false);
|
||||
// The File Explorer (the toolbar button where Upload was); the tree is
|
||||
// re-read when it closes after changing anything.
|
||||
const [explorer, setExplorer] = useState<{ path: string } | null>(null);
|
||||
const explorerChanged = useRef(false);
|
||||
const download = (path: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = api.lanes.gitDownloadUrl(target, path);
|
||||
a.download = path.split("/").pop() || path;
|
||||
a.click();
|
||||
};
|
||||
const pickUpload = (dir: string) => {
|
||||
uploadDir.current = dir;
|
||||
uploadInput.current?.click();
|
||||
};
|
||||
const upload = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
const lane = laneId;
|
||||
const dir = uploadDir.current;
|
||||
setUploading(true);
|
||||
let saved = 0;
|
||||
try {
|
||||
for (const f of files) {
|
||||
try {
|
||||
await api.lanes.gitUpload(target, dir, f);
|
||||
saved++;
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("changes.uploadFailed", {
|
||||
name: f.name,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (laneNow.current === lane) {
|
||||
setUploading(false);
|
||||
if (saved) {
|
||||
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
|
||||
// Re-read the level it landed in, and open it.
|
||||
void api.lanes
|
||||
.gitTree(target, dir)
|
||||
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
|
||||
.catch(() => undefined);
|
||||
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCollapsed = (path: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -2047,6 +2166,36 @@ 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"
|
||||
/>
|
||||
{/* 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}
|
||||
session={session}
|
||||
source={source}
|
||||
initialPath={explorer?.path ?? ""}
|
||||
onChanged={() => (explorerChanged.current = true)}
|
||||
onClose={() => {
|
||||
setExplorer(null);
|
||||
if (explorerChanged.current) {
|
||||
explorerChanged.current = false;
|
||||
void rereadAll();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={uploadInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="lane-changes-upload-input"
|
||||
onChange={(e) => {
|
||||
const files = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
void upload(files);
|
||||
}}
|
||||
/>
|
||||
{!hits && (
|
||||
<IconButton
|
||||
label={
|
||||
@@ -2148,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"
|
||||
@@ -2510,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>
|
||||
@@ -2566,13 +2719,16 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<label
|
||||
title={t("changes.amendHint")}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
|
||||
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
|
||||
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
|
||||
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="lane-changes-amend"
|
||||
checked={amend}
|
||||
disabled={headPushed}
|
||||
onChange={(e) => setAmend(e.target.checked)}
|
||||
/>
|
||||
{t("changes.amend")}
|
||||
@@ -2758,6 +2914,18 @@ export function LaneChangesPanel({
|
||||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{!local && range !== "commit" && openFile && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-download"
|
||||
onClick={() => download(openFile)}
|
||||
title={t("changes.download")}
|
||||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{t("changes.download")}
|
||||
</button>
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{/* A working file opens in the editor; the read-only diff
|
||||
(Whole file off) keeps an Edit button back into it. */}
|
||||
@@ -2838,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}
|
||||
|
||||
@@ -242,13 +242,27 @@ export function LaneGitBar({
|
||||
void run(action, body);
|
||||
}
|
||||
};
|
||||
const push = () =>
|
||||
confirmRun(
|
||||
const push = async () => {
|
||||
let ahead = git.ahead ?? 0;
|
||||
if (git.upstream && ahead === 0) {
|
||||
// The facts can be up to 30 s old (an agent may have just committed):
|
||||
// ask git again before saying there is nothing to push — and never
|
||||
// ask to confirm pushing zero commits.
|
||||
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
|
||||
const fresh = read?.available ? read : null;
|
||||
ahead = fresh?.ahead ?? 0;
|
||||
if (fresh?.upstream && ahead === 0) {
|
||||
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
|
||||
onChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return confirmRun(
|
||||
git.upstream
|
||||
? {
|
||||
title: t("gitBar.push"),
|
||||
message: t("gitBar.pushConfirm", {
|
||||
count: git.ahead ?? 0,
|
||||
count: ahead,
|
||||
branch: git.branch,
|
||||
upstream: git.upstream,
|
||||
}),
|
||||
@@ -259,6 +273,7 @@ export function LaneGitBar({
|
||||
},
|
||||
"push"
|
||||
);
|
||||
};
|
||||
const publish = () =>
|
||||
confirmRun(
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* the sidebar's **New session** row). Every item is something the row already
|
||||
* does: open it the way its click does (a live pane attaches, an ended session
|
||||
* opens its conversation — never a resume), open a live one in a split,
|
||||
* rename / archive / unarchive when the list offers them, copy its id, open
|
||||
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
|
||||
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
||||
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -29,6 +29,7 @@ export function SessionMenu({
|
||||
onRename,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onPin,
|
||||
}: MenuPoint & {
|
||||
laneId: number;
|
||||
session: Session | null;
|
||||
@@ -38,6 +39,8 @@ export function SessionMenu({
|
||||
onRename?: () => void;
|
||||
onArchive?: () => void;
|
||||
onUnarchive?: () => void;
|
||||
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||
onPin?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -84,6 +87,11 @@ export function SessionMenu({
|
||||
</MenuItem>
|
||||
)}
|
||||
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
|
||||
{onPin && session && (
|
||||
<MenuItem onSelect={pick(onPin)}>
|
||||
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
|
||||
* pane above the ended ones) — never the other
|
||||
* (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
|
||||
* pinned ones on top in pin order, then the ones with a live pane, then the
|
||||
* ended ones; a pin is `PATCH {pinned}`, issue #80) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* renames it, its hover buttons pin/unpin and archive it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing); a right-click opens the same actions and more
|
||||
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
@@ -33,7 +34,7 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
@@ -278,17 +279,21 @@ export function WorkspaceSessionList({
|
||||
3000
|
||||
);
|
||||
const refreshKey = `${hookKey}:${version}`;
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const own = useSessionPage(
|
||||
{ lane: lane.id, archived: false, pinnedFirst: true, limit },
|
||||
refreshKey
|
||||
);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||
// Live sessions first, ended ones under them — each group stays newest
|
||||
// first (the sort is stable over the server's order).
|
||||
// Pinned sessions first, then live ones, then ended ones — each group in
|
||||
// the server's order (pin order, else newest first; the sort is stable).
|
||||
const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2);
|
||||
const shown = (sessions ?? [])
|
||||
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
|
||||
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
// A live tab whose session has no row yet: shown as a New session at the
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
@@ -336,7 +341,10 @@ export function WorkspaceSessionList({
|
||||
if (count !== null) onMatches?.(count);
|
||||
}, [count, onMatches]);
|
||||
|
||||
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||||
const patch = async (
|
||||
s: Session,
|
||||
body: { name?: string; archived?: boolean; pinned?: boolean }
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.sessions.patch(s.id, body);
|
||||
@@ -533,6 +541,13 @@ export function WorkspaceSessionList({
|
||||
/>
|
||||
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
{s.pinned_at && (
|
||||
<Pin
|
||||
data-testid={`workspace-session-pinned-${s.id}`}
|
||||
aria-label={t("tree.pinned")}
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
{sessionWorktreeName(lane, s) && (
|
||||
<GitBranch
|
||||
data-testid={`workspace-session-worktree-${s.id}`}
|
||||
@@ -540,7 +555,9 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
<span
|
||||
className={`ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
>
|
||||
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
@@ -548,6 +565,22 @@ export function WorkspaceSessionList({
|
||||
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
/>
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-pin-${s.id}`}
|
||||
onClick={() => void patch(s, { pinned: !s.pinned_at })}
|
||||
title={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
|
||||
aria-label={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
|
||||
className="absolute right-6 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
{s.pinned_at ? (
|
||||
<PinOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -608,6 +641,9 @@ export function WorkspaceSessionList({
|
||||
onClose={sessionCtx.close}
|
||||
onRename={() => startRename(sessionCtx.menu!.item)}
|
||||
onArchive={() => void archive(sessionCtx.menu!.item)}
|
||||
onPin={() =>
|
||||
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,8 +25,11 @@ vi.mock("../../../lib/api", () => ({
|
||||
pipelines: vi.fn(),
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
list: vi.fn(() => Promise.resolve({ lanes: [], counts: {} })),
|
||||
get: vi.fn(),
|
||||
profileInit: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
@@ -154,6 +157,7 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -582,17 +586,15 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the picker on every click into the path field", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
|
||||
it("a click into the path field only focuses it — no picker opens (#77)", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
const field = document.getElementById("add-lane-repo")!;
|
||||
|
||||
await user.click(field);
|
||||
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
|
||||
await user.click(field);
|
||||
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
|
||||
expect(field).toHaveFocus();
|
||||
expect(api.lanes.pickPath).not.toHaveBeenCalled();
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
||||
@@ -614,7 +616,7 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||
it("opens the browser, lists subdirectories, and a double-click opens one", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
@@ -627,8 +629,8 @@ describe("AddLaneModal — folder browse", () => {
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
||||
await user.dblClick(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false, false);
|
||||
});
|
||||
|
||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||
@@ -668,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: /Which CLI drives this workspace/ });
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
).toEqual(["Claude Code", "Codex", "Gemini"]);
|
||||
await user.click(within(list).getByRole("button", { name: "Codex" }));
|
||||
// The picked tool replaces the default on the button, and the list closes.
|
||||
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
|
||||
);
|
||||
it("offers no tool picker: a new session picks its own AI", async () => {
|
||||
renderModal({});
|
||||
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: /Claude Code/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Codex" })).toBeNull();
|
||||
});
|
||||
|
||||
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
|
||||
@@ -779,3 +753,76 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — Clone", () => {
|
||||
async function fillClone(user: ReturnType<typeof userEvent.setup>, url: string) {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, url);
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
}
|
||||
|
||||
it("clones into <folder>/<repo name> and reports the new workspace", async () => {
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 5 }),
|
||||
created: true,
|
||||
path: "/home/an/code/repo",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
expect(screen.getByText(/cloned to \/home\/an\/code\/repo/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "git@github.com:owner/repo.git", parent: "/home/an/code" })
|
||||
)
|
||||
);
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("needs a login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "git.example.com" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 6 }), created: true, path: "/x" });
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "https://git.example.com/o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
const login = await screen.findByTestId("add-lane-clone-login");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("git.example.com needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "an");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[1]?.[0].credentials).toEqual({
|
||||
username: "an",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("points to Settings → Git when the SSH key is refused", async () => {
|
||||
vi.mocked(api.lanes.clone).mockRejectedValue(
|
||||
Object.assign(new Error("refused"), { code: "ESSHAUTH" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "git@github.com:o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
const link = await screen.findByRole("link", { name: "Open Settings → Git" });
|
||||
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||
|
||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
|
||||
if (body?.url.includes("private") && !body.credentials)
|
||||
throw Object.assign(new Error("login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "private.test" },
|
||||
});
|
||||
return view;
|
||||
});
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("workspace-clone-input"),
|
||||
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
|
||||
await user.type(screen.getByLabelText("Username"), "dev");
|
||||
await user.type(screen.getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file Tests for the workspace File Explorer modal: it lists a folder and
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
homes: vi.fn(),
|
||||
downloadUrl: (t: unknown, p: string) => `/dl/${JSON.stringify(t)}/${p}`,
|
||||
list: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
newFile: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
move: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
trash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const root: FsListing = {
|
||||
path: "",
|
||||
parent: null,
|
||||
truncated: false,
|
||||
entries: [
|
||||
{ name: "src", path: "src", type: "dir", size: null, mtime: 0 },
|
||||
{ name: "a.txt", path: "a.txt", type: "file", size: 12, mtime: 0 },
|
||||
],
|
||||
};
|
||||
const src: FsListing = {
|
||||
path: "src",
|
||||
parent: "",
|
||||
truncated: false,
|
||||
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two" },
|
||||
],
|
||||
counts: {},
|
||||
} 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.homes).mockResolvedValue({
|
||||
homes: [
|
||||
{ id: "me", label: "An", readOnly: false },
|
||||
{ id: "bob", label: "Bob", readOnly: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const open = () => render(<FileExplorer open laneId={1} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
const row = (name: string) =>
|
||||
screen.getAllByTestId("fx-row").find((r) => r.textContent?.includes(name))!;
|
||||
|
||||
describe("FileExplorer", () => {
|
||||
it("hides dot files until Ctrl+H, like the Linux file manager", async () => {
|
||||
vi.mocked(api.files.list).mockResolvedValue({
|
||||
...root,
|
||||
entries: [
|
||||
...root.entries,
|
||||
{ name: ".env.example", path: ".env.example", type: "file", size: 1, mtime: 0 },
|
||||
],
|
||||
});
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
expect(screen.queryByText(".env.example")).toBeNull();
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "h", ctrlKey: true });
|
||||
expect(await screen.findByText(".env.example")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a labelled action bar for a selection, and goes Back after opening a folder", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(row("a.txt"));
|
||||
const bar = screen.getByTestId("fx-actionbar");
|
||||
expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument();
|
||||
expect(within(bar).getByRole("button", { name: /Move to Trash/ })).toBeInTheDocument();
|
||||
fireEvent.doubleClick(row("src"));
|
||||
await screen.findByText("x.js");
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, ""));
|
||||
});
|
||||
|
||||
it("lists a folder and opens a sub-folder on double-click", async () => {
|
||||
open();
|
||||
expect(await screen.findByText("a.txt")).toBeInTheDocument();
|
||||
fireEvent.doubleClick(row("src"));
|
||||
expect(await screen.findByText("x.js")).toBeInTheDocument();
|
||||
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
|
||||
});
|
||||
|
||||
it("makes a folder inline", async () => {
|
||||
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "New folder" }));
|
||||
const name = screen.getByLabelText("Name");
|
||||
await user.clear(name);
|
||||
await user.type(name, "docs{Enter}");
|
||||
await waitFor(() => expect(api.files.mkdir).toHaveBeenCalledWith(1, "docs"));
|
||||
});
|
||||
|
||||
it("deletes into the Trash without asking", async () => {
|
||||
vi.mocked(api.files.remove).mockResolvedValue({ trashed: ["id1"] });
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.click(row("a.txt"));
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
|
||||
await waitFor(() => expect(api.files.remove).toHaveBeenCalledWith(1, ["a.txt"]));
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("copies here and pastes into another workspace", async () => {
|
||||
vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.click(row("a.txt"));
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true });
|
||||
await user.click(await screen.findByRole("button", { name: "two" }));
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
await user.click(screen.getByRole("button", { name: /Paste 1 item/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.copy).toHaveBeenCalledWith(2, ["a.txt"], "", {
|
||||
fromLane: 1,
|
||||
conflict: "fail",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("asks about a taken name on upload, and sends the choice", async () => {
|
||||
vi.mocked(api.files.upload)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
||||
)
|
||||
.mockResolvedValueOnce({ path: "a (1).txt" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const file = new File(["hi"], "a.txt");
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), file);
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.upload).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a batch over 500 MB before sending anything", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const big = new File(["x"], "big.bin");
|
||||
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
||||
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores from the Trash", async () => {
|
||||
vi.mocked(api.files.trash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "abc12345",
|
||||
name: "a.txt",
|
||||
laneId: 1,
|
||||
path: "a.txt",
|
||||
isDir: false,
|
||||
deletedAt: "2026-10-14T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.files.restore).mockResolvedValue({ path: "a.txt", laneId: null });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(screen.getByRole("button", { name: "Trash" }));
|
||||
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"));
|
||||
});
|
||||
|
||||
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
|
||||
vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(await screen.findByRole("button", { name: "Home" }));
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith({ home: "me" }, ""));
|
||||
fireEvent.click(row("a.txt"));
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true });
|
||||
await user.click(screen.getByRole("button", { name: "one" }));
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, ""));
|
||||
await user.click(screen.getByRole("button", { name: /Paste 1 item/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.copy).toHaveBeenCalledWith(1, ["a.txt"], "", {
|
||||
fromHome: "me",
|
||||
conflict: "fail",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("shows another user's home read-only: no New, Upload, Cut, Rename or Delete", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
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();
|
||||
await user.click(row("a.txt"));
|
||||
const bar = screen.getByTestId("fx-actionbar");
|
||||
expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument();
|
||||
expect(within(bar).getByRole("button", { name: /Copy/ })).toBeInTheDocument();
|
||||
expect(within(bar).queryByRole("button", { name: /Cut|Rename|Move to Trash/ })).toBeNull();
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @file FolderBrowseModal.test.tsx
|
||||
* @description Unit tests for the in-app folder browser laid out like a
|
||||
* native picker: a click selects and Select picks that folder (else the
|
||||
* current one), a double-click opens, Backspace and the breadcrumb go up,
|
||||
* the filter narrows the list, Places open Home and a workspace, the path
|
||||
* field (Ctrl+L) opens a typed path, New folder makes one and selects it,
|
||||
* Show hidden re-lists with dot entries, and a Select is remembered as Recent.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { api } from "../../../lib/api";
|
||||
import { FolderBrowseModal } from "../FolderBrowseModal";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { browse: vi.fn(), mkdir: vi.fn(), list: vi.fn() } },
|
||||
}));
|
||||
|
||||
const TREE: Record<string, string[]> = {
|
||||
"/home/u": ["proj", "notes"],
|
||||
"/home/u/proj": ["api", "web"],
|
||||
"/home/u/proj/api": [],
|
||||
"/home/u/proj/web": [],
|
||||
"/home/u/notes": [],
|
||||
"/srv/ws": [],
|
||||
};
|
||||
|
||||
function listing(p: string, hidden = false) {
|
||||
const names = [...(TREE[p] ?? []), ...(hidden ? [".cfg"] : [])];
|
||||
return {
|
||||
path: p,
|
||||
parent: p === "/home/u" ? null : p.slice(0, p.lastIndexOf("/")) || "/",
|
||||
home: "/home/u",
|
||||
entries: names.map((n, i) => ({
|
||||
name: n,
|
||||
path: `${p}/${n}`,
|
||||
isGitRepo: n === "api",
|
||||
mtime: 1_700_000_000_000 + i,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(api.lanes.browse)
|
||||
.mockReset()
|
||||
.mockImplementation(async (p?: string, _f?: boolean, hidden?: boolean) => {
|
||||
const at = p || "/home/u";
|
||||
if (!(at in TREE)) throw new Error("path does not exist");
|
||||
return listing(at, hidden);
|
||||
});
|
||||
vi.mocked(api.lanes.mkdir).mockReset();
|
||||
vi.mocked(api.lanes.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
lanes: [{ id: 1, title: "Shop API", cwd: "/srv/ws" }],
|
||||
counts: {},
|
||||
} as never);
|
||||
});
|
||||
|
||||
const open = (onSelect = vi.fn(), initialPath?: string) => {
|
||||
render(
|
||||
<FolderBrowseModal open initialPath={initialPath} onSelect={onSelect} onClose={vi.fn()} />
|
||||
);
|
||||
return screen.findByRole("dialog", { name: "Browse for a folder" });
|
||||
};
|
||||
|
||||
const option = (dialog: HTMLElement, name: string) =>
|
||||
within(dialog).findByRole("option", { name: new RegExp(name) });
|
||||
|
||||
describe("FolderBrowseModal", () => {
|
||||
it("opens an admin in their own home; the Server folder tick opens the host's", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.browse).mockImplementation(async (p, _f, _h, host) => ({
|
||||
...listing(p || (host ? "/home/u" : "/srv/ws"), false),
|
||||
home: host ? "/home/u" : "/srv/ws",
|
||||
hostHome: "/home/u",
|
||||
}));
|
||||
const dialog = await open();
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, false)
|
||||
);
|
||||
const tick = within(dialog).getByTestId("browse-host-folder");
|
||||
expect(tick).not.toBeChecked();
|
||||
await user.click(tick);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, true)
|
||||
);
|
||||
expect(tick).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows no Server folder tick without a host home in the listing", async () => {
|
||||
const dialog = await open();
|
||||
expect((await within(dialog).findAllByRole("option")).length).toBeGreaterThan(0);
|
||||
expect(within(dialog).queryByTestId("browse-host-folder")).toBeNull();
|
||||
});
|
||||
|
||||
it("click selects, double-click opens, Select picks the selection else the folder", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const dialog = await open(onSelect);
|
||||
await userEvent.click(await option(dialog, "proj"));
|
||||
expect(await option(dialog, "proj")).toHaveAttribute("aria-selected", "true");
|
||||
await userEvent.dblClick(await option(dialog, "proj"));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("/home/u/proj");
|
||||
});
|
||||
|
||||
it("Enter opens the selected folder and Backspace goes back up to it", async () => {
|
||||
const dialog = await open();
|
||||
const list = within(dialog).getByRole("listbox");
|
||||
await userEvent.click(await option(dialog, "proj"));
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await option(dialog, "api");
|
||||
list.focus();
|
||||
await userEvent.keyboard("{Backspace}");
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByRole("option", { name: /proj/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("the breadcrumb, Back and Forward move between folders", async () => {
|
||||
const dialog = await open(vi.fn(), "/home/u/proj/api");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "proj" }));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false, false)
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false, false)
|
||||
);
|
||||
// Above the user's own folder (it answered no parent) nothing is a link.
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||
await option(dialog, "notes");
|
||||
expect(within(dialog).getByRole("button", { name: "home" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("the filter narrows the list, and Enter on a single match opens it", async () => {
|
||||
const dialog = await open();
|
||||
await option(dialog, "notes");
|
||||
await userEvent.type(within(dialog).getByRole("textbox", { name: "Filter…" }), "pro");
|
||||
expect(within(dialog).queryByRole("option", { name: /notes/ })).toBeNull();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await option(dialog, "web");
|
||||
});
|
||||
|
||||
it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => {
|
||||
const dialog = await open(vi.fn(), "/home/u/proj");
|
||||
await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false, false)
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||
await option(dialog, "notes");
|
||||
await userEvent.keyboard("{Control>}l{/Control}");
|
||||
const field = within(dialog).getByRole("textbox", { name: "Type a path (Ctrl+L)" });
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "/home/u/proj/web{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false, false)
|
||||
);
|
||||
});
|
||||
|
||||
it("New folder makes one in the current folder and selects it", async () => {
|
||||
vi.mocked(api.lanes.mkdir).mockImplementation(async (parent, name) => {
|
||||
TREE[parent] = [...(TREE[parent] ?? []), name];
|
||||
return { path: `${parent}/${name}` };
|
||||
});
|
||||
const dialog = await open();
|
||||
await option(dialog, "proj");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /New folder/ }));
|
||||
await userEvent.type(
|
||||
within(dialog).getByRole("textbox", { name: "Folder name" }),
|
||||
"fresh{Enter}"
|
||||
);
|
||||
expect(api.lanes.mkdir).toHaveBeenCalledWith("/home/u", "fresh");
|
||||
await waitFor(async () =>
|
||||
expect(await option(dialog, "fresh")).toHaveAttribute("aria-selected", "true")
|
||||
);
|
||||
TREE["/home/u"] = ["proj", "notes"];
|
||||
});
|
||||
|
||||
it("Show hidden re-lists with dot entries, and a pick is kept as Recent", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const dialog = await open(onSelect);
|
||||
await option(dialog, "proj");
|
||||
expect(within(dialog).queryByRole("option", { name: /\.cfg/ })).toBeNull();
|
||||
await userEvent.click(within(dialog).getByRole("checkbox", { name: /Show hidden/ }));
|
||||
await option(dialog, "\\.cfg");
|
||||
await userEvent.dblClick(await option(dialog, "proj"));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("/home/u/proj");
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.browse.recent") || "[]")).toEqual([
|
||||
"/home/u/proj",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
@@ -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;
|
||||
@@ -75,6 +81,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitTree: vi.fn(),
|
||||
gitTreeAll: vi.fn(),
|
||||
gitBlame: vi.fn(),
|
||||
gitDownloadUrl: (id: number, path: string) =>
|
||||
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
|
||||
gitUpload: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -143,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());
|
||||
@@ -307,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 () => {
|
||||
@@ -545,6 +568,94 @@ 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,
|
||||
entries:
|
||||
path === "out"
|
||||
? [{ name: "report.md", path: "out/report.md", dir: false }]
|
||||
: [{ name: "out", path: "out", dir: true }],
|
||||
truncated: false,
|
||||
}));
|
||||
vi.mocked(api.lanes.gitUpload).mockResolvedValue({ path: "out/data.csv" });
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
const input = within(sidebar).getByTestId("lane-changes-upload-input") as HTMLInputElement;
|
||||
const picked = vi.spyOn(input, "click").mockImplementation(() => {});
|
||||
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
|
||||
await user.click(screen.getByTestId("lane-changes-menu-upload"));
|
||||
expect(picked).toHaveBeenCalled();
|
||||
const file = new File(["a,b\n"], "data.csv", { type: "text/csv" });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
await waitFor(() => expect(api.lanes.gitUpload).toHaveBeenCalledWith(7, "out", file));
|
||||
// The folder it landed in is re-read and opened.
|
||||
expect(await within(sidebar).findByText("report.md")).toBeInTheDocument();
|
||||
|
||||
const clicked: string[] = [];
|
||||
const anchor = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
||||
this: HTMLAnchorElement
|
||||
) {
|
||||
clicked.push(this.getAttribute("href") ?? "");
|
||||
});
|
||||
await user.pointer({ keys: "[MouseRight]", target: within(sidebar).getByText("report.md") });
|
||||
await user.click(screen.getByTestId("lane-changes-menu-download"));
|
||||
expect(clicked).toEqual(["/api/lanes/7/git/download?path=out%2Freport.md"]);
|
||||
anchor.mockRestore();
|
||||
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,
|
||||
@@ -1687,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} />);
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { LaneGitFacts } from "../../../lib/types";
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn(), git: vi.fn() } },
|
||||
}));
|
||||
|
||||
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
||||
@@ -164,6 +164,29 @@ describe("LaneGitBar", () => {
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says there is nothing to push instead of asking to push zero commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 0 }));
|
||||
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /^Push$/ }));
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-reads stale facts: a commit made since still gets the push confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 1 }));
|
||||
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /^Push$/ }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Push 1 commit") })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
||||
* `~/.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`. Anything
|
||||
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
|
||||
* 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
|
||||
* or the project's); a built-in skill has no file and shows by name only.
|
||||
@@ -106,6 +108,14 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
// An image it read elsewhere (a screenshot under ~/.cache): served
|
||||
// only because this session's own Read named it.
|
||||
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
|
||||
} else if (scope.sessionId && /^(?:\/|[A-Za-z]:\/)/.test(file)) {
|
||||
// A text file it read or wrote elsewhere (a report under ~/Documents):
|
||||
// served only because this session's own Read/Write/Edit named it.
|
||||
show(
|
||||
api.sessions
|
||||
.readFile(scope.sessionId, path)
|
||||
.then((f) => ({ kind: "text", text: f.text, truncated: f.truncated }))
|
||||
);
|
||||
} else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
|
||||
@@ -16,7 +16,14 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type DragEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
@@ -35,6 +42,8 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type SessionOutline } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import { isShown } from "../../hooks/useOnScreen";
|
||||
import { displayName, extOf } from "./ChatFilePreview";
|
||||
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
|
||||
|
||||
@@ -49,25 +58,30 @@ const EMPTY: SessionOutline = {
|
||||
mcp: [],
|
||||
};
|
||||
|
||||
/** The session's outline, re-read on a visibility-gated poll. */
|
||||
export function useSessionOutline(sessionId: string): SessionOutline {
|
||||
/** The session's outline, re-read on a visibility-gated poll — skipped too
|
||||
* while `onScreen` says the pane is hidden (another workspace or tab, kept
|
||||
* mounted). An unchanged answer keeps the old object: no re-render. */
|
||||
export function useSessionOutline(
|
||||
sessionId: string,
|
||||
onScreen?: RefObject<boolean>
|
||||
): SessionOutline {
|
||||
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.outline(sessionId)
|
||||
.then((o) => live && setOutline({ ...EMPTY, ...o }))
|
||||
.then((o) => live && setOutline(keepIfSame<SessionOutline>({ ...EMPTY, ...o })))
|
||||
.catch(() => {});
|
||||
void load();
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
if (onScreen ? isShown(onScreen) : document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, onScreen]);
|
||||
return outline;
|
||||
}
|
||||
|
||||
@@ -271,7 +285,7 @@ export function ChatOutline({
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse border-accent" : "border-border"}`}
|
||||
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse-dot border-accent" : "border-border"}`}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Plus } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -89,6 +89,13 @@ export function LaneConsolePane({
|
||||
: null;
|
||||
const chat = useSessionChat(chatSession);
|
||||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||||
// Stable, so the memoized terminal is not re-rendered for a new closure.
|
||||
const onRunExitRef = useRef(onRunExit);
|
||||
onRunExitRef.current = onRunExit;
|
||||
const onTerminalExit = useCallback(() => {
|
||||
setHandle(null);
|
||||
onRunExitRef.current?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
@@ -210,13 +217,11 @@ export function LaneConsolePane({
|
||||
runId={handle.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={autoFocus && !(chatable && chat)}
|
||||
covered={!!(chatable && chat)}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// the tab bar moves on (another tab, or the home's prompt box)
|
||||
// rather than leaving a dead terminal the user has to click out of.
|
||||
onExit={() => {
|
||||
setHandle(null);
|
||||
onRunExit?.();
|
||||
}}
|
||||
onExit={onTerminalExit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
@@ -65,6 +65,7 @@ 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 {
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
@@ -207,7 +209,7 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
||||
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
||||
* pane; its "Type something" row opens a field right there instead —
|
||||
* pressing that row's digit again would type the digit into the CLI's own. */
|
||||
function PaneMenuCard({
|
||||
export function PaneMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onAnswer,
|
||||
@@ -306,7 +308,10 @@ function PaneMenuCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneChat({
|
||||
/** Memoized: a lane_update re-renders the Workspace's pane tree, and every
|
||||
* prop here is a primitive or the pane's own run handle — nothing in a
|
||||
* frame about another lane reaches the chat. */
|
||||
export const PaneChat = memo(function PaneChat({
|
||||
run,
|
||||
sessionId,
|
||||
cwd,
|
||||
@@ -370,7 +375,9 @@ export function PaneChat({
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const [list, setList] = useState<OutlineList | null>(null);
|
||||
const activity = useSessionOutline(sessionId);
|
||||
// Kept mounted in a hidden workspace or tab: its pollers skip until shown.
|
||||
const onScreen = useOnScreen(rootRef);
|
||||
const activity = useSessionOutline(sessionId, onScreen);
|
||||
// Open by default only in a spanning pane, and only once there is an output.
|
||||
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
@@ -421,9 +428,12 @@ export function PaneChat({
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
read();
|
||||
const timer = window.setInterval(read, 700);
|
||||
// Nobody can answer a menu in a hidden tab or pane: no screen scan then.
|
||||
const timer = window.setInterval(() => {
|
||||
if (isShown(onScreen)) read();
|
||||
}, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId]);
|
||||
}, [runId, onScreen]);
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
@@ -451,10 +461,28 @@ export function PaneChat({
|
||||
model: null,
|
||||
});
|
||||
const onContext = useCallback(
|
||||
(tokens: number, model: string | null) => setContext({ tokens, model }),
|
||||
(tokens: number, model: string | null) =>
|
||||
setContext((prev) =>
|
||||
prev.tokens === tokens && prev.model === model ? prev : { tokens, model }
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// Stable, so typing in the prompt box does not re-render the conversation
|
||||
// (ConversationView is memoized).
|
||||
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
|
||||
const openFile = useCallback(
|
||||
(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;
|
||||
setPreview(path);
|
||||
},
|
||||
[laneId, cwd]
|
||||
);
|
||||
|
||||
// Before the session starts the CLI may be asking in the terminal (trust
|
||||
// this folder? with "No, exit" preselected): an Enter from here answers it.
|
||||
// Asked, not refused: whether the session row appears right after the
|
||||
@@ -503,15 +531,10 @@ export function PaneChat({
|
||||
follow={follow}
|
||||
waiting={asking}
|
||||
stopped={stops}
|
||||
onOpenTerminal={() => showTerminal(runId, sessionId)}
|
||||
onOpenTerminal={openTerminal}
|
||||
onContext={onContext}
|
||||
onNotStarted={setNotStarted}
|
||||
onOpenFile={(path) => {
|
||||
// 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;
|
||||
setPreview(path);
|
||||
}}
|
||||
onOpenFile={openFile}
|
||||
/>
|
||||
{!outlineOpen && !preview && (
|
||||
<button
|
||||
@@ -678,4 +701,4 @@ export function PaneChat({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
* 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
|
||||
* saved and the terminals refit once, not on every pointer move.
|
||||
* `kept` panes (another workspace's live sessions) are hidden children of
|
||||
* the SAME list, keyed by `keyOf`, so a pane moves between shown and kept
|
||||
* without remounting — a workspace switch never re-attaches its terminals.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -365,8 +368,14 @@ export function PaneGrid({
|
||||
onNewSplit,
|
||||
stateOf,
|
||||
onStop,
|
||||
keyOf = (slot) => slot,
|
||||
kept = [],
|
||||
}: {
|
||||
layout: PaneLayout;
|
||||
/** A pane's React key: unique across `mounted` and `kept` (a `paneKey`). */
|
||||
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). */
|
||||
onStop?: (slot: number) => void;
|
||||
/** A live AI run's state for its header dot (none for a shell). */
|
||||
@@ -490,13 +499,25 @@ export function PaneGrid({
|
||||
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
||||
}}
|
||||
>
|
||||
{mounted.map((slot) => {
|
||||
{[
|
||||
...mounted.map((slot) => ({ key: keyOf(slot), slot, node: null as ReactNode })),
|
||||
...kept.map((k) => ({ key: k.key, slot: null, node: k.node })),
|
||||
].map(({ key, slot, node }) => {
|
||||
if (slot === null) {
|
||||
// The same element shape as a hidden pane below, so React keeps it.
|
||||
return (
|
||||
<div key={key} data-testid={`pane-kept-${key}`} className="hidden">
|
||||
{false}
|
||||
<div className="flex min-h-0 flex-1 flex-col">{node}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const r = rectOf.get(slot);
|
||||
const run = runOf(slot);
|
||||
const focused = layout.focus === slot;
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
key={key}
|
||||
data-testid={`pane-${slot}`}
|
||||
data-focused={r && focused ? "true" : undefined}
|
||||
// A menu opened in this pane stays inside it (`menuRoom`).
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
addAttachments,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -412,7 +413,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 +427,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 +446,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";
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||
* a file. The pane also registers with
|
||||
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -32,7 +32,8 @@
|
||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
||||
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
|
||||
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
|
||||
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
||||
* READ the user's clipboard) — over plain HTTP too, through `writeClipboard`'s
|
||||
* textarea fallback. While the socket is down a "Reconnecting…"
|
||||
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
|
||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||
@@ -41,6 +42,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
@@ -59,11 +61,13 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -85,9 +89,11 @@ const clipboardProvider: IClipboardProvider = {
|
||||
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
|
||||
// clipboard. An empty write (a "clear") is ignored.
|
||||
writeText: (_selection, text) => {
|
||||
// Silent and best effort: no toast per copy, and on a non-secure origin
|
||||
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
|
||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
// Silent and best effort: no toast per copy. On a non-secure origin
|
||||
// (`http://<lan-ip>`, no Clipboard API) writeClipboard falls back to the
|
||||
// textarea copy, which the browser allows while the drag that made the
|
||||
// selection still counts as the user's gesture.
|
||||
if (text) void writeClipboard(text);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -134,6 +140,10 @@ export function loadXterm(): Promise<Xterm> {
|
||||
const RETRY_MIN_MS = 1500;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
/** A text drag (a Files/Changes row, a skill, selected text): xterm.js has no
|
||||
* drop handling of its own, so the pane pastes it, as a local terminal does. */
|
||||
const hasText = (e: DragEvent) =>
|
||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
@@ -145,9 +155,22 @@ interface TerminalViewProps {
|
||||
/** Take the keyboard when it appears (default). Off for a split pane that
|
||||
* is not the focused one, or keys would land in whichever mounted last. */
|
||||
autoFocus?: boolean;
|
||||
/** Another layer (the pane's chat) covers it. xterm stops drawing only when
|
||||
* its IntersectionObserver says off screen, and a covered terminal is not —
|
||||
* so it is moved out of the viewport with a transform, which keeps its
|
||||
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||
covered?: boolean;
|
||||
}
|
||||
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: TerminalViewProps) {
|
||||
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
|
||||
* a terminal only cares about its own props (its owner keeps `onExit` stable). */
|
||||
export const TerminalView = memo(function TerminalView({
|
||||
runId,
|
||||
wsBaseUrl,
|
||||
onExit,
|
||||
autoFocus = true,
|
||||
covered = false,
|
||||
}: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
// every render, and as an effect dep it would tear the terminal down and
|
||||
@@ -200,7 +223,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const ctrlVRef = useRef(false);
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
if (hasText(e)) {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDrop(null);
|
||||
const text = e.dataTransfer.getData("text/plain");
|
||||
if (text) termRef.current?.paste(text + " ");
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
void attachFiles(Array.from(e.dataTransfer.files));
|
||||
@@ -267,7 +299,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||
// and why it only showed up once content scrolled.
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontSize: readTerminalFontSize(),
|
||||
cursorBlink: true,
|
||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
|
||||
@@ -374,6 +406,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
}, 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;
|
||||
@@ -559,6 +601,17 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
// Settings → Appearance changed the terminal's font or size: an open
|
||||
// pane takes it now, re-measured and re-fitted like a font landing.
|
||||
const onFontPref = () => {
|
||||
const size = readTerminalFontSize();
|
||||
const family = TERMINAL_FONTS[readTerminalFont()];
|
||||
if (term.options.fontSize === size && term.options.fontFamily?.trimEnd() === family) return;
|
||||
term.options.fontSize = size;
|
||||
term.options.fontFamily = family;
|
||||
refit();
|
||||
};
|
||||
window.addEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
|
||||
return () => {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
@@ -566,6 +619,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
detachIme();
|
||||
resizeDisposable.dispose();
|
||||
@@ -618,16 +672,27 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
|
||||
},
|
||||
// The rows on screen, dim cells (a TUI's placeholder) left out.
|
||||
() => {
|
||||
// `menuOnly` (the chat's 700 ms menu poll): plain text first, and the
|
||||
// cell-by-cell read only when a row could be a menu's cursor row.
|
||||
(menuOnly = false) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return null;
|
||||
const buf = term.buffer.active;
|
||||
if (menuOnly) {
|
||||
let maybe = false;
|
||||
for (let y = 0; y < term.rows && !maybe; y++)
|
||||
maybe = MENU_CURSOR_ROW.test(
|
||||
buf.getLine(buf.baseY + y)?.translateToString(true) ?? ""
|
||||
);
|
||||
if (!maybe) return null;
|
||||
}
|
||||
const rows: string[] = [];
|
||||
const reuse = buf.getNullCell();
|
||||
for (let y = 0; y < term.rows; y++) {
|
||||
const line = buf.getLine(buf.baseY + y);
|
||||
let row = "";
|
||||
for (let x = 0; line && x < line.length; x++) {
|
||||
const cell = line.getCell(x);
|
||||
const cell = line.getCell(x, reuse);
|
||||
// Width 0: the right half of a wide character.
|
||||
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
|
||||
}
|
||||
@@ -673,26 +738,31 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
setDrop(hasFiles(e) ? "hint" : "paste");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" || d === "paste" ? null : d));
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
onPasteCapture={onPaste}
|
||||
onFocus={() => notePaneFocus(runId)}
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={covered ? { transform: "translateX(-300vw)" } : undefined}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
<p id={hintId} className="sr-only">
|
||||
{t("terminal.keyboardHint")}
|
||||
</p>
|
||||
@@ -793,4 +863,4 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* pane's folder through the lane's git file read, a memory file under
|
||||
* `~/.claude` through the config pages' read, a dropped text file through the
|
||||
* drop route, an image read outside the folder through the session's image
|
||||
* route, a Windows path like a POSIX one, anything else by path only; the
|
||||
* route, a text file read or written outside it through the session's file route, a Windows path like a POSIX one, anything else by path only; the
|
||||
* list dialog previewing the file picked in it; and the skills dialog finding
|
||||
* each skill's SKILL.md by name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -31,7 +31,14 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
sessions: { readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}` },
|
||||
sessions: {
|
||||
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
||||
readFile: vi.fn((_id: string, p: string) =>
|
||||
p.endsWith(".md")
|
||||
? Promise.resolve({ text: "# Audit report", truncated: false })
|
||||
: Promise.reject(new Error("404"))
|
||||
),
|
||||
},
|
||||
ccConfig: {
|
||||
skills: vi.fn().mockResolvedValue({
|
||||
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
|
||||
@@ -110,9 +117,16 @@ describe("FilePreview", () => {
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", () => {
|
||||
it("reads a text file it wrote outside the folder through the session", async () => {
|
||||
const report = "/home/a/Documents/audit/REPORT_ssh_ec2.md";
|
||||
render(<FilePreview path={report} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Audit report")).toBeTruthy();
|
||||
expect(vi.mocked(api.sessions.readFile)).toHaveBeenCalledWith("s1", report);
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", async () => {
|
||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
expect(await screen.findByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
skills: [],
|
||||
mcp: [],
|
||||
}),
|
||||
readFile: r({ text: "", truncated: false }),
|
||||
},
|
||||
lanes: {
|
||||
gitFile: r({
|
||||
@@ -355,7 +356,9 @@ describe("PaneChat — the activity panel", () => {
|
||||
);
|
||||
const first = render(pane());
|
||||
fireEvent.click(
|
||||
within(await screen.findByTestId("chat-outline")).getByRole("button", { name: "Hide side panel" })
|
||||
within(await screen.findByTestId("chat-outline")).getByRole("button", {
|
||||
name: "Hide side panel",
|
||||
})
|
||||
);
|
||||
first.unmount();
|
||||
render(pane());
|
||||
@@ -431,6 +434,45 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
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.
|
||||
|
||||
@@ -512,6 +512,29 @@ describe("TerminalView", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("pastes the text of a dragged Files-panel row, uploading nothing", () => {
|
||||
dropFileMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const data: Record<string, string> = {
|
||||
"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] ?? "",
|
||||
};
|
||||
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
|
||||
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();
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploads a pasted image and pastes its path, like a drop", async () => {
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||
pasteMock.mockClear();
|
||||
@@ -565,6 +588,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", () => {
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @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
|
||||
* 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
|
||||
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
|
||||
* admin's to change, on the Users page — never here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LogOut, RefreshCw } from "lucide-react";
|
||||
import { api, type MyAccount } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { ROLE_BADGE } from "../users/UserEditDialog";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
|
||||
|
||||
const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unknown) => {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`users:errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`users:errors.${err.code}`)
|
||||
: err.message;
|
||||
};
|
||||
|
||||
export function AccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [account, setAccount] = useState<ServerAccount | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const { updateUser } = useAuth();
|
||||
const apply = useCallback(
|
||||
(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 });
|
||||
},
|
||||
[updateUser]
|
||||
);
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null);
|
||||
api.me
|
||||
.account()
|
||||
.then(apply)
|
||||
.catch((e: Error) => setLoadError(e.message));
|
||||
}, [apply]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
|
||||
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
|
||||
<button type="button" className="btn-ghost text-xs" onClick={load}>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!account) {
|
||||
return <div className="card h-40 animate-pulse" data-testid="account-loading" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ProfileCard account={account} />
|
||||
<ProfileForm account={account} onSaved={apply} />
|
||||
<PasswordForm />
|
||||
<SignOutOthers logins={account.logins} onDone={load} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileCard({ account }: { account: ServerAccount }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { user } = account;
|
||||
const shown = user.displayName || user.username;
|
||||
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="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
@{user.username}
|
||||
{user.email ? ` · ${user.email}` : ""}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
|
||||
{t(`users:roles.${user.role}`)}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted">{t(`users:roleHint.${user.role}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
|
||||
<Stat label={t("account.memberSince")}>
|
||||
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
|
||||
</Stat>
|
||||
<Stat label={t("account.workspaces")}>{account.workspaces}</Stat>
|
||||
<Stat label={t("account.sessions")}>{account.sessions}</Stat>
|
||||
<Stat label={t("account.logins")}>{account.logins}</Stat>
|
||||
<Stat label={t("account.lastLogin")}>
|
||||
{account.lastLoginAt ? formatDateTime(account.lastLoginAt) : "—"}
|
||||
</Stat>
|
||||
</dl>
|
||||
<p className="mt-3 text-[11px] text-fg-muted">{t("account.roleNote")}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileForm({
|
||||
account,
|
||||
onSaved,
|
||||
}: {
|
||||
account: ServerAccount;
|
||||
onSaved: (r: MyAccount) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [displayName, setDisplayName] = useState(account.user.displayName ?? "");
|
||||
const [email, setEmail] = useState(account.user.email ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
|
||||
const body: { displayName?: string | null; email?: string | null } = {};
|
||||
if (norm(displayName) !== (account.user.displayName ?? null))
|
||||
body.displayName = norm(displayName);
|
||||
if (norm(email) !== (account.user.email ?? null)) body.email = norm(email);
|
||||
const changed = Object.keys(body).length > 0;
|
||||
|
||||
const save = async () => {
|
||||
if (!changed || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
onSaved(await api.me.updateAccount(body));
|
||||
toast.success(t("account.profileSaved"));
|
||||
} catch (e) {
|
||||
toast.error(errText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="account-profile"
|
||||
title={t("account.profile")}
|
||||
description={t("account.profileDescription")}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2" onKeyDown={onKeyDown}>
|
||||
<Field label={t("users:displayName")}>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={displayName}
|
||||
maxLength={100}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
aria-label={t("users:displayName")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("users:email")}>
|
||||
<input
|
||||
type="email"
|
||||
className="input w-full text-sm"
|
||||
value={email}
|
||||
maxLength={254}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-label={t("users:email")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary disabled:opacity-50"
|
||||
disabled={!changed || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("account.save")}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordForm() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mismatch = again.length > 0 && again !== next;
|
||||
const ready = current.length > 0 && next.length > 0 && again === next && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.me.changePassword(current, next);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setAgain("");
|
||||
toast.success(t("account.passwordChanged"));
|
||||
} catch (e) {
|
||||
setError(errText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="account-password"
|
||||
title={t("account.password")}
|
||||
description={t("account.passwordDescription")}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3" onKeyDown={onKeyDown}>
|
||||
<Field label={t("account.currentPassword")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
autoComplete="current-password"
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
aria-label={t("account.currentPassword")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={next}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
aria-label={t("account.newPassword")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("account.confirmPassword")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={again}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setAgain(e.target.value)}
|
||||
aria-label={t("account.confirmPassword")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{(mismatch || error) && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error ?? t("account.mismatch")}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary disabled:opacity-50"
|
||||
disabled={!ready}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{t("account.changePassword")}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutOthers({ logins, onDone }: { logins: number; onDone: () => void }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("account.signOutOthersConfirm"),
|
||||
message: t("account.signOutOthersBody"),
|
||||
confirmLabel: t("account.signOutOthers"),
|
||||
cancelLabel: t("common:cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.me.signOutOthers();
|
||||
toast.success(t("account.signedOut", { count: r.ended }));
|
||||
onDone();
|
||||
} catch (e) {
|
||||
toast.error(errText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SettingsSection
|
||||
id="account-sessions"
|
||||
title={t("account.sessionsTitle")}
|
||||
description={t("account.sessionsDescription", { n: logins })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-status-danger disabled:opacity-50"
|
||||
disabled={busy || logins <= 1}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
<LogOut className="h-4 w-4" /> {t("account.signOutOthers")}
|
||||
</button>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[11px] text-fg-muted">{label}</dt>
|
||||
<dd className="truncate text-sm tabular-nums text-fg-primary">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
|
||||
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`), the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* @description Settings → Appearance (`/settings/appearance`), grouped so
|
||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`); then the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -34,6 +37,15 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
readTerminalFontSize,
|
||||
readUiFontSize,
|
||||
writeTerminalFontSize,
|
||||
writeUiFontSize,
|
||||
type UiFontSize,
|
||||
} from "../../lib/fontSize";
|
||||
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
@@ -82,6 +94,8 @@ export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
@@ -103,31 +117,11 @@ export function AppearanceSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ChatUiSettings />
|
||||
|
||||
<SettingsSection
|
||||
id="workspace-tabs"
|
||||
title={t("workspaceTabs.title")}
|
||||
description={t("workspaceTabs.description")}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={t("workspaceTabs.label")}
|
||||
description={t("workspaceTabs.labelDesc")}
|
||||
checked={allTabs}
|
||||
onChange={(on) => {
|
||||
writeAllTabs(on);
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
description={t("language.description")}
|
||||
>
|
||||
<SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
|
||||
<SettingsRow
|
||||
id="language"
|
||||
label={t("language.label")}
|
||||
description={t("language.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
@@ -140,16 +134,34 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="terminal-font"
|
||||
title={t("terminalFont.title")}
|
||||
description={t("terminalFont.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
id="text-size"
|
||||
label={t("textSize.label")}
|
||||
description={t("textSize.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("textSize.label")}
|
||||
value={String(uiSize)}
|
||||
onChange={(v) => {
|
||||
const size = Number(v) as UiFontSize;
|
||||
writeUiFontSize(size);
|
||||
setUiSize(size);
|
||||
}}
|
||||
options={UI_FONT_SIZES.map((size) => ({
|
||||
value: String(size),
|
||||
label: t(`textSize.names.${size}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
id="terminal-font"
|
||||
label={t("terminalFont.title")}
|
||||
description={
|
||||
termFont === "system"
|
||||
? `${t("terminalFont.description")} ${t("terminalFont.systemNote")}`
|
||||
: t("terminalFont.description")
|
||||
}
|
||||
control={
|
||||
// A dropdown: five font names overflow a segmented row.
|
||||
<Select<TerminalFont>
|
||||
@@ -163,17 +175,57 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.sizeLabel")}
|
||||
control={
|
||||
<Select
|
||||
label={t("terminalFont.sizeLabel")}
|
||||
value={String(termSize)}
|
||||
onChange={(v) => {
|
||||
writeTerminalFontSize(Number(v));
|
||||
setTermSize(Number(v));
|
||||
}}
|
||||
options={TERMINAL_FONT_SIZES.map((size) => ({
|
||||
value: String(size),
|
||||
label: `${size}px`,
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p
|
||||
data-testid="terminal-font-preview"
|
||||
aria-label={t("terminalFont.preview")}
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-fg-primary"
|
||||
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||||
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
|
||||
// In px like xterm's cells: the interface's text size does not scale it.
|
||||
style={{
|
||||
fontFamily: TERMINAL_FONTS[termFont],
|
||||
fontSize: termSize,
|
||||
fontVariantLigatures: "none",
|
||||
}}
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="sessions-view"
|
||||
title={t("sessionsView.title")}
|
||||
description={t("sessionsView.description")}
|
||||
>
|
||||
<ChatUiSettings />
|
||||
<SettingsSwitchRow
|
||||
id="workspace-tabs"
|
||||
label={t("workspaceTabs.label")}
|
||||
description={t("workspaceTabs.labelDesc")}
|
||||
checked={allTabs}
|
||||
onChange={(on) => {
|
||||
writeAllTabs(on);
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
|
||||
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
|
||||
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* @file ChatUiSettings — Settings → Appearance → Sessions, the Chat UI row
|
||||
* (`#chat-ui`): whether a Claude or Codex session opens as a chat like Claude
|
||||
* Desktop (`PaneChat`) instead of its terminal. A row, not a card: it sits in
|
||||
* the Sessions section beside the workspace-tabs switch. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* account on a shared dashboard keeps its own choice on any browser.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -9,25 +10,22 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { saveChatUi, useChatUi } from "../../lib/chatUi";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function ChatUiSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const chatUi = useChatUi();
|
||||
return (
|
||||
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(
|
||||
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSwitchRow
|
||||
id="chat-ui"
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }))
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @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
|
||||
* 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 { useTranslation } from "react-i18next";
|
||||
import { Copy, KeyRound, Plus, RefreshCw, 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";
|
||||
|
||||
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";
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [form, setForm] = useState<{ host: string; username: string; token: string } | null>(null);
|
||||
const auth = useAuth().state;
|
||||
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);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setId(await api.gitIdentity.get());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<GitIdentity>, done?: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setId(await fn());
|
||||
if (done) toast.success(done);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
|
||||
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), keyType),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
};
|
||||
|
||||
const removeKey = async () => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKey());
|
||||
};
|
||||
|
||||
const saveLogin = async () => {
|
||||
if (!form) return;
|
||||
// A bare host is the usual entry; a pasted URL is reduced to its host.
|
||||
let host = form.host.trim();
|
||||
try {
|
||||
if (/^https?:\/\//i.test(host)) host = new URL(host).host;
|
||||
} catch {
|
||||
/* the server answers EBADHOST */
|
||||
}
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.saveHttps({ host, username: form.username.trim(), password: form.token }),
|
||||
t("gitCreds.saved", { host })
|
||||
);
|
||||
if (ok) setForm(null);
|
||||
};
|
||||
|
||||
const removeLogin = async (host: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeLoginConfirm", { host }),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeHttps(host));
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
return error ? (
|
||||
<p className="text-xs text-status-danger">{error}</p>
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted">{t("gitCreds.loading")}</p>
|
||||
);
|
||||
}
|
||||
const member = id.account === "member";
|
||||
const authorShown = author ?? {
|
||||
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
|
||||
email: id.author.email ?? me?.email ?? "",
|
||||
};
|
||||
const authorUnset = !id.author.name || !id.author.email;
|
||||
const authorDirty =
|
||||
authorUnset ||
|
||||
authorShown.name.trim() !== (id.author.name ?? "") ||
|
||||
authorShown.email.trim() !== (id.author.email ?? "");
|
||||
const saveAuthor = async () => {
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.saveAuthor({
|
||||
name: authorShown.name.trim(),
|
||||
email: authorShown.email.trim(),
|
||||
}),
|
||||
t("gitIdentity.authorSaved")
|
||||
);
|
||||
if (ok) setAuthor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="max-w-2xl text-xs text-fg-muted">
|
||||
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAuthor();
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-fg-primary">{t("gitIdentity.authorTitle")}</h3>
|
||||
{authorUnset && (
|
||||
<p className="text-[11px] text-status-warning">{t("gitIdentity.authorUnset")}</p>
|
||||
)}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
aria-label={t("gitIdentity.authorName")}
|
||||
placeholder={t("gitIdentity.authorName")}
|
||||
value={authorShown.name}
|
||||
disabled={!id.writable}
|
||||
onChange={(e) => setAuthor({ ...authorShown, name: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
aria-label={t("gitIdentity.authorEmail")}
|
||||
placeholder="name@example.com"
|
||||
value={authorShown.email}
|
||||
disabled={!id.writable}
|
||||
onChange={(e) => setAuthor({ ...authorShown, email: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !authorDirty || !authorShown.name.trim() || !authorShown.email.trim()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="max-w-2xl space-y-2 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 && (
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={keyType}
|
||||
disabled={busy}
|
||||
onChange={(e) => setKeyType(e.target.value === "rsa" ? "rsa" : "ed25519")}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="ed25519">{t("gitIdentity.keyEd25519")}</option>
|
||||
<option value="rsa">{t("gitIdentity.keyRsa")}</option>
|
||||
</select>
|
||||
)}
|
||||
{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.type === "rsa" ? "RSA 4096 · " : id.ssh.type ? "ED25519 · " : ""}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{member && (
|
||||
<div className="max-w-2xl 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")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => setForm({ host: "", username: "", token: "" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitCreds.add")}
|
||||
</button>
|
||||
</div>
|
||||
{form && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveLogin();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldHost")}
|
||||
placeholder="github.com"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm({ ...form, host: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldUser")}
|
||||
placeholder={t("gitCreds.fieldUser")}
|
||||
autoComplete="off"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitCreds.fieldToken")}
|
||||
placeholder={t("gitCreds.fieldToken")}
|
||||
autoComplete="new-password"
|
||||
value={form.token}
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !form.host.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setForm(null)} className={BTN}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{id.https.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noLogins")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-xs">
|
||||
{id.https.map((c) => (
|
||||
<li key={c.host} className="flex items-center gap-2 py-1.5">
|
||||
<span className="font-mono text-[11px] text-fg-primary">{c.host}</span>
|
||||
<span className="flex-1 text-fg-secondary">{c.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitIdentity.remove")} ${c.host}`}
|
||||
title={t("gitIdentity.remove")}
|
||||
disabled={busy}
|
||||
onClick={() => void removeLogin(c.host)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,15 +37,19 @@ export function SettingsRow({
|
||||
description,
|
||||
control,
|
||||
alignTop = false,
|
||||
id,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
control: ReactNode;
|
||||
alignTop?: boolean;
|
||||
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
id={id}
|
||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
description ? "py-3" : "py-2"
|
||||
} ${alignTop ? "items-start" : "items-center"}`}
|
||||
>
|
||||
@@ -64,15 +68,18 @@ export function SettingsSwitchRow({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
id,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<SettingsRow
|
||||
id={id}
|
||||
label={label}
|
||||
description={description}
|
||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @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.
|
||||
* @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";
|
||||
|
||||
const me = vi.hoisted(() => ({
|
||||
account: vi.fn(),
|
||||
updateAccount: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
signOutOthers: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { me } };
|
||||
});
|
||||
const confirm = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: confirm }));
|
||||
|
||||
import { AccountSettings } from "../AccountSettings";
|
||||
|
||||
const account = {
|
||||
mode: "server" as const,
|
||||
user: {
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer" as const,
|
||||
disabled: false,
|
||||
displayName: "Mia Tran",
|
||||
email: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
},
|
||||
workspaces: 3,
|
||||
sessions: 12,
|
||||
logins: 2,
|
||||
lastLoginAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(me)) fn.mockReset();
|
||||
me.account.mockResolvedValue(account);
|
||||
me.updateAccount.mockResolvedValue(account);
|
||||
me.changePassword.mockResolvedValue({ ok: true });
|
||||
me.signOutOthers.mockResolvedValue({ ended: 1 });
|
||||
confirm.mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("AccountSettings", () => {
|
||||
it("shows the account's own information", async () => {
|
||||
render(<AccountSettings />);
|
||||
const card = await screen.findByTestId("account-card");
|
||||
expect(card.textContent).toContain("Mia Tran");
|
||||
expect(card.textContent).toContain("@mia");
|
||||
expect(card.textContent).toContain("Developer");
|
||||
expect(card.textContent).toContain("12");
|
||||
});
|
||||
|
||||
it("saves only a changed field", async () => {
|
||||
const ue = userEvent.setup();
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
|
||||
await ue.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(me.updateAccount).toHaveBeenCalledWith({ email: "mia@x.vn" }));
|
||||
});
|
||||
|
||||
it("refuses two different new passwords", async () => {
|
||||
const ue = userEvent.setup();
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
|
||||
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-999");
|
||||
expect(screen.getByText("The two new passwords differ.")).toBeTruthy();
|
||||
const button = screen.getByRole("button", { name: "Change password" });
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
expect(me.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("changes the password with the current one", async () => {
|
||||
const ue = userEvent.setup();
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
|
||||
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
|
||||
await ue.click(screen.getByRole("button", { name: "Change password" }));
|
||||
await waitFor(() =>
|
||||
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123")
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before signing the other devices out", async () => {
|
||||
const ue = userEvent.setup();
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
await ue.click(screen.getByRole("button", { name: "Sign out other devices" }));
|
||||
expect(confirm).toHaveBeenCalled();
|
||||
await waitFor(() => expect(me.signOutOthers).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the text size, the terminal font and size pickers, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("AppearanceSettings", () => {
|
||||
root.classList.remove("dark");
|
||||
root.removeAttribute("data-theme");
|
||||
root.removeAttribute("data-mode");
|
||||
root.style.removeProperty("font-size");
|
||||
});
|
||||
|
||||
it("switches the interface language and holds the pet block", async () => {
|
||||
@@ -102,4 +103,33 @@ describe("AppearanceSettings", () => {
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets the interface text size on <html>, the default leaving it unset", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface text" });
|
||||
expect(within(group).getByRole("radio", { name: "Default" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Larger" }));
|
||||
expect(root.style.fontSize).toBe("18px");
|
||||
expect(localStorage.getItem("agenthub.ui.fontSize")).toBe("18");
|
||||
await user.click(within(group).getByRole("radio", { name: "Default" }));
|
||||
expect(root.style.fontSize).toBe("");
|
||||
});
|
||||
|
||||
it("picks the terminal font size and tells open terminals", async () => {
|
||||
const user = userEvent.setup();
|
||||
const heard = vi.fn();
|
||||
window.addEventListener("agenthub-font-size", heard);
|
||||
render(<AppearanceSettings />);
|
||||
expect(screen.getByRole("button", { name: /^Size/ })).toHaveTextContent("13px");
|
||||
await user.click(screen.getByRole("button", { name: /^Size/ }));
|
||||
await user.click(screen.getByRole("option", { name: "16px" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.fontSize")).toBe("16");
|
||||
expect(screen.getByTestId("terminal-font-preview").style.fontSize).toBe("16px");
|
||||
expect(heard).toHaveBeenCalled();
|
||||
window.removeEventListener("agenthub-font-size", heard);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @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.
|
||||
* @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 { GitIdentitySettings } from "../GitIdentitySettings";
|
||||
import { api, type GitIdentity } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { copyText } from "../../../lib/clipboard";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
gitIdentity: {
|
||||
get: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
removeKey: vi.fn(),
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
removeHttps: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
||||
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
||||
|
||||
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
||||
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
account: "member",
|
||||
writable: true,
|
||||
ssh: null,
|
||||
https: [],
|
||||
author: { name: "An", email: "an@x.vn" },
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GitIdentitySettings", () => {
|
||||
it("makes a key when there is none, without asking", async () => {
|
||||
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" }));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
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 () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY }));
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Copy public key" }));
|
||||
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, "ed25519"));
|
||||
});
|
||||
|
||||
it("lets a member save an https login of their own", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||
vi.mocked(api.gitIdentity.saveHttps).mockResolvedValue(
|
||||
view({ https: [{ protocol: "https", host: "github.com", username: "an", hasToken: true }] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Add login" }));
|
||||
await userEvent.type(screen.getByLabelText("Server URL"), "https://github.com/o/r");
|
||||
await userEvent.type(screen.getByLabelText("Username"), "an");
|
||||
await userEvent.type(screen.getByLabelText("Password or access token"), "tok");
|
||||
// The second Save: the first belongs to the commit-author form.
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[1]!);
|
||||
expect(api.gitIdentity.saveHttps).toHaveBeenCalledWith({
|
||||
host: "github.com",
|
||||
username: "an",
|
||||
password: "tok",
|
||||
});
|
||||
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("makes an RSA key when that type is picked (AWS CodeCommit takes only RSA)", 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 userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
||||
expect(await screen.findByText(/^RSA 4096 · /)).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());
|
||||
render(<GitIdentitySettings />);
|
||||
expect(
|
||||
await screen.findByText(/commits from the dashboard and your agents fail/)
|
||||
).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Name"), "An Nguyen");
|
||||
await userEvent.type(screen.getByLabelText("Email"), "an@x.vn");
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[0]!);
|
||||
expect(api.gitIdentity.saveAuthor).toHaveBeenCalledWith({
|
||||
name: "An Nguyen",
|
||||
email: "an@x.vn",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the host's key read-only and no https list", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ account: "host", writable: false, ssh: KEY })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* @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
|
||||
* 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
|
||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
||||
* password again; an analyst only reads. The secret only goes IN: the server
|
||||
* never sends it back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
} from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
name: string;
|
||||
kind: string;
|
||||
email: string | null;
|
||||
refreshError: string | null;
|
||||
addedAt: string;
|
||||
expired: boolean;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
function rowsOf(pool: AccountPool): Row[] {
|
||||
return [
|
||||
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
|
||||
...pool.codex.map((a) => ({
|
||||
...a,
|
||||
tool: "codex" as const,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`pool.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
api.accountPool
|
||||
.list(force)
|
||||
.then((r) => {
|
||||
setPool(r);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(() => load(true));
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const rows = pool ? rowsOf(pool) : [];
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="account-pool">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<KeyRound 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("pool.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
title={t("pool.refresh")}
|
||||
aria-label={t("pool.refresh")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && rows.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("pool.account")}</th>
|
||||
<th className={th}>{t("pool.tool")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.quota")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.spend")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{t(`pool.kind.${r.kind}`)}
|
||||
{r.expired && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.livePanes}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{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-[11px] text-fg-muted">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d) })}
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoving(r)}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
onClose={() => setAdding(false)}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.done.added", { name }));
|
||||
setAdding(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{removing && (
|
||||
<RemoveAccountDialog
|
||||
row={removing}
|
||||
onClose={() => setRemoving(null)}
|
||||
onDone={() => {
|
||||
toast.success(t("pool.done.removed", { name: removing.name }));
|
||||
setRemoving(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ClaudeSource = "profile" | "credentials" | "token";
|
||||
|
||||
function AddAccountDialog({
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>("claude");
|
||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
const [profile, setProfile] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles()
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
|
||||
const fromProfile = tool === "claude" && source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
(fromProfile ? profile.length > 0 : name.trim().length > 0 && secret.trim().length > 0);
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const body: PoolAccountInput =
|
||||
tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
: source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
try {
|
||||
const r = await api.accountPool.add(body, current);
|
||||
onDone(r.account.name);
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// The secret box is a textarea for pasted JSON: Enter there is a newline.
|
||||
if ((e.target as HTMLElement).tagName !== "TEXTAREA" && isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
const secretKind = tool === "codex" ? "codex" : source;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.add")}
|
||||
confirmLabel={t("pool.add")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
<Field label={t("pool.tool")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => setTool(e.target.value as PoolTool)}
|
||||
aria-label={t("pool.tool")}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
{tool === "claude" && (
|
||||
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{(["profile", "credentials", "token"] as const).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{fromProfile && (
|
||||
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
aria-label={t("pool.profile")}
|
||||
>
|
||||
<option value="">
|
||||
{profiles === null
|
||||
? t("pool.loadingProfiles")
|
||||
: profiles.length === 0
|
||||
? t("pool.noProfiles")
|
||||
: t("pool.chooseProfile")}
|
||||
</option>
|
||||
{profiles?.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.label}
|
||||
{p.email ? ` — ${p.email}` : ""}
|
||||
{p.active ? ` (${t("pool.activeHere")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label={t("pool.name")}
|
||||
hint={fromProfile ? t("pool.nameFromProfile") : t("pool.nameHint")}
|
||||
>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("pool.name")}
|
||||
/>
|
||||
</Field>
|
||||
{!fromProfile && (
|
||||
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
||||
{secretKind === "token" ? (
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full font-mono text-sm"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t(`pool.secret.${secretKind}`)}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className="input w-full h-24 font-mono text-xs"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={t(`pool.secret.${secretKind}`)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveAccountDialog({
|
||||
row,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
row: Row;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.accountPool.remove(row.tool, row.name, current);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.remove", { name: row.name })}
|
||||
message={t("pool.removeHint")}
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
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="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,558 @@
|
||||
/**
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
* the server validates the whole change before it applies any of it. A
|
||||
* refusal is shown inside the dialog, which stays open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
export const ROLE_BADGE: Record<Role, string> = {
|
||||
admin: "bg-accent/15 text-accent border-accent/20",
|
||||
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
|
||||
developer: "bg-surface-4 text-fg-secondary border-border",
|
||||
};
|
||||
|
||||
/** A random password of `n` characters, no look-alikes (0/O, 1/l/I). */
|
||||
export function generatePassword(n = 16): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%";
|
||||
const bytes = new Uint32Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
}
|
||||
|
||||
type OwnedWorkspace = { id: number; title: string; cwd: string };
|
||||
|
||||
export function UserEditDialog({
|
||||
user,
|
||||
self,
|
||||
users,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: ServerUserUsage;
|
||||
/** The account being edited is the signed-in admin's own. */
|
||||
self: boolean;
|
||||
users: ServerUserUsage[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
|
||||
const [displayName, setDisplayName] = useState(user.displayName ?? "");
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [role, setRole] = useState<Role>(user.role);
|
||||
const [active, setActive] = useState(!user.disabled);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
const [removed, setRemoved] = useState<Set<number>>(new Set());
|
||||
const [added, setAdded] = useState<number[]>([]);
|
||||
const [pick, setPick] = useState("");
|
||||
const [signOut, setSignOut] = useState(false);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.get(user.id)
|
||||
.then((r) => setOwned(r.workspaces))
|
||||
.catch(() => setOwned([]));
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, [user.id]);
|
||||
|
||||
const ownerName = (id: number) => {
|
||||
const u = users.find((x) => x.id === id);
|
||||
return u ? u.displayName || u.username : `#${id}`;
|
||||
};
|
||||
const ownedIds = useMemo(() => new Set((owned ?? []).map((w) => w.id)), [owned]);
|
||||
const addable = (lanes ?? []).filter((l) => !ownedIds.has(l.id) && !added.includes(l.id));
|
||||
const laneLabel = (id: number) => {
|
||||
const l = (lanes ?? []).find((x) => x.id === id);
|
||||
return l ? l.title || l.cwd : `#${id}`;
|
||||
};
|
||||
|
||||
const patch: UserPatch = {};
|
||||
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
|
||||
if (norm(displayName) !== (user.displayName ?? null)) patch.displayName = norm(displayName);
|
||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||
if (!self && role !== user.role) patch.role = role;
|
||||
if (!self && active === user.disabled) patch.disabled = !active;
|
||||
if (password) patch.password = password;
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// No minimum length (server/lib/auth.js) — a blank field keeps the password.
|
||||
const ready = changed && current.length > 0 && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.update(user.id, patch, current);
|
||||
onDone(t("done.updated", { name: user.displayName || user.username }));
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
/** 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();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
const shown = user.displayName || user.username;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-edit-title"
|
||||
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
|
||||
>
|
||||
{shown.slice(0, 2)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
|
||||
{t("edit.title", { name: user.displayName || user.username })}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
|
||||
{t(`roles.${user.role}`)}
|
||||
</span>
|
||||
{self && (
|
||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||
{t("you")}
|
||||
</span>
|
||||
)}
|
||||
{user.disabled && (
|
||||
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common:close")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
||||
<dl
|
||||
className="grid grid-cols-2 gap-3 rounded-lg border border-border bg-surface-2 p-3 text-xs sm:grid-cols-3"
|
||||
data-testid="user-edit-info"
|
||||
>
|
||||
<Info label={t("edit.created")}>
|
||||
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
|
||||
</Info>
|
||||
<Info label={t("edit.lastLogin")}>
|
||||
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t("edit.never")}
|
||||
</Info>
|
||||
<Info label={t("edit.logins")}>{user.logins ?? 0}</Info>
|
||||
<Info label={t("workspaces")}>{user.workspaces}</Info>
|
||||
<Info label={t("sessions")}>{user.sessions}</Info>
|
||||
<Info label={t("cost30d")}>{fmtCost(user.last30d.cost)}</Info>
|
||||
</dl>
|
||||
|
||||
<Section title={t("edit.profile")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label={t("displayName")} hint={t("displayNameHint")}>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={100}
|
||||
aria-label={t("displayName")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("email")}>
|
||||
<input
|
||||
type="email"
|
||||
className="input w-full text-sm"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={254}
|
||||
autoComplete="off"
|
||||
aria-label={t("email")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.access")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label={t("role")} hint={self ? t("edit.selfRole") : t(`roleHint.${role}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={role}
|
||||
disabled={self}
|
||||
onChange={(e) => setRole(e.target.value as Role)}
|
||||
aria-label={t("role")}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`roles.${r}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("edit.status")}</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
checked={active}
|
||||
onChange={setActive}
|
||||
disabled={self}
|
||||
label={t("edit.active")}
|
||||
/>
|
||||
<span className="text-sm text-fg-secondary">
|
||||
{active ? t("edit.active") : t("disabled")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">
|
||||
{self ? t("edit.selfDisable") : t("edit.statusHint")}
|
||||
</span>
|
||||
{!active && !user.disabled && (
|
||||
<p className="mt-1 text-[11px] text-status-danger">
|
||||
{t("dialog.disableBody", { name: user.displayName || user.username })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
<Field label={t("newPassword")} hint={t("edit.passwordHint")}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="input w-full font-mono text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="new-password"
|
||||
aria-label={t("newPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("edit.hide") : t("edit.show")}
|
||||
title={showPassword ? t("edit.hide") : t("edit.show")}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
disabled={!password}
|
||||
onClick={() => void copyText(password)}
|
||||
aria-label={t("edit.copy")}
|
||||
title={t("edit.copy")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
onClick={() => {
|
||||
setPassword(generatePassword());
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> {t("edit.generate")}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title={t("workspaces")}>
|
||||
{owned === null ? (
|
||||
<p className="text-xs text-fg-muted">{t("edit.loading")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid="user-edit-workspaces">
|
||||
{owned.length === 0 && added.length === 0 && (
|
||||
<li className="text-xs text-fg-muted">{t("edit.noOwned")}</li>
|
||||
)}
|
||||
{owned.map((w) => {
|
||||
const gone = removed.has(w.id);
|
||||
return (
|
||||
<li
|
||||
key={w.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${gone ? "text-fg-muted line-through" : "text-fg-primary"}`}
|
||||
title={w.cwd}
|
||||
>
|
||||
{w.title || w.cwd}
|
||||
</span>
|
||||
{gone ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent hover:underline"
|
||||
onClick={() =>
|
||||
setRemoved((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(w.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("edit.undo")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label={t("edit.removeWorkspace", { name: w.title || w.cwd })}
|
||||
title={t("edit.removeWorkspaceHint")}
|
||||
onClick={() => setRemoved((s) => new Set(s).add(w.id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{added.map((id) => (
|
||||
<li
|
||||
key={`add-${id}`}
|
||||
className="flex items-center gap-2 rounded-md border border-accent/30 bg-accent/5 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary">{laneLabel(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label={t("edit.undo")}
|
||||
onClick={() => setAdded((a) => a.filter((x) => x !== id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{addable.length > 0 && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={pick}
|
||||
onChange={(e) => setPick(e.target.value)}
|
||||
aria-label={t("edit.addWorkspace")}
|
||||
>
|
||||
<option value="">{t("edit.chooseWorkspace")}</option>
|
||||
{addable.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{(l.title || l.cwd) +
|
||||
" — " +
|
||||
(l.owner_id == null
|
||||
? t("noOwner")
|
||||
: t("ownedBy", { name: ownerName(l.owner_id) }))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
disabled={!pick}
|
||||
onClick={() => {
|
||||
setAdded((a) => [...a, Number(pick)]);
|
||||
setPick("");
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> {t("edit.addWorkspace")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(added.length > 0 || removed.size > 0) && (
|
||||
<p className="mt-1 text-[11px] text-fg-muted">
|
||||
{t("dialog.workspaceBody", { name: user.displayName || user.username })}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.sessions")}>
|
||||
<Checkbox
|
||||
checked={signOut}
|
||||
onChange={setSignOut}
|
||||
tone="warning"
|
||||
label={t("edit.signOut")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">
|
||||
{t("edit.signOutHint", { n: user.logins ?? 0 })}
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border bg-surface-2/40 px-5 py-4">
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</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={`text-xs text-fg-muted ${self ? "mr-auto" : ""}`}>
|
||||
{t("edit.noChanges")}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="btn-ghost">
|
||||
{t("common:cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={!ready}
|
||||
aria-busy={busy}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[11px] text-fg-muted">{label}</dt>
|
||||
<dd className="truncate text-sm tabular-nums text-fg-primary">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,155 @@
|
||||
/**
|
||||
* @file AccountPoolCard.test.tsx
|
||||
* @description The Users page's account pool card: lists the Claude and Codex
|
||||
* accounts with their live panes and quota, says what an empty pool means, an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove 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 { AccountPool } from "../../../lib/api";
|
||||
|
||||
const accountPool = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { accountPool } };
|
||||
});
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
|
||||
const POOL: AccountPool = {
|
||||
accounts: [
|
||||
{
|
||||
name: "team-1",
|
||||
kind: "login",
|
||||
email: "team1@example.com",
|
||||
refreshError: null,
|
||||
addedAt: "2026-10-01T00:00:00Z",
|
||||
expiresAt: null,
|
||||
expired: false,
|
||||
livePanes: 2,
|
||||
spend5h: 1.5,
|
||||
spend7d: 12,
|
||||
quotaPercent: 42,
|
||||
full: false,
|
||||
},
|
||||
],
|
||||
codex: [
|
||||
{
|
||||
name: "cx-1",
|
||||
kind: "apikey",
|
||||
addedAt: "2026-10-02T00:00:00Z",
|
||||
livePanes: 0,
|
||||
spend5h: 0,
|
||||
spend7d: 0,
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
},
|
||||
],
|
||||
blockPercent: 90,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
name: "work",
|
||||
label: "Work",
|
||||
email: "w@example.com",
|
||||
subscriptionType: "max",
|
||||
active: false,
|
||||
},
|
||||
{ name: "me", label: "Me", email: "me@example.com", subscriptionType: "pro", active: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("lists every account, and explains an empty pool", async () => {
|
||||
const { unmount } = render(<AccountPoolCard isAdmin={false} />);
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).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();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves a saved login in, the server's own included", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const select = await screen.findByLabelText("Profile");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||
expect(screen.getByRole("option", { name: /Me/ })).toBeEnabled();
|
||||
await user.selectOptions(select, "work");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a setup-token only with the admin's own password", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm).toBeEnabled();
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "team-2", token: "sk-ant-oat01-xyz" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("removes an account with the admin's own password", async () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @file UserEditDialog.test.tsx
|
||||
* @description The admin's account editor: Save stays off until something
|
||||
* changed AND the admin typed their own password; one PATCH carries only the
|
||||
* changed fields (profile, role, disable, workspace add/remove, sign-out); an
|
||||
* admin editing their own row cannot touch role or state; Generate fills a
|
||||
* 16-character password.
|
||||
* @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 { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
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>>();
|
||||
return { ...actual, api: { users, lanes } };
|
||||
});
|
||||
|
||||
import { UserEditDialog, generatePassword } from "../UserEditDialog";
|
||||
|
||||
const usage = { cost: 0, tokens: 0 };
|
||||
const mia: ServerUserUsage = {
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
displayName: null,
|
||||
email: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
workspaces: 1,
|
||||
sessions: 3,
|
||||
last7d: usage,
|
||||
last30d: usage,
|
||||
logins: 2,
|
||||
lastLoginAt: null,
|
||||
};
|
||||
|
||||
function open(user = mia, self = false) {
|
||||
const onDone = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<UserEditDialog user={user} self={self} users={[mia]} onClose={onClose} onDone={onDone} />
|
||||
);
|
||||
return { onDone, onClose };
|
||||
}
|
||||
|
||||
const save = () => screen.getByRole("button", { name: "Save" });
|
||||
|
||||
beforeEach(() => {
|
||||
users.get.mockReset().mockResolvedValue({
|
||||
user: mia,
|
||||
workspaces: [{ id: 5, title: "api", cwd: "/w/api" }],
|
||||
});
|
||||
users.update.mockReset().mockResolvedValue({ user: mia });
|
||||
lanes.list.mockReset().mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 5, title: "api", cwd: "/w/api", owner_id: 2 },
|
||||
{ id: 7, title: "web", cwd: "/w/web", owner_id: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserEditDialog", () => {
|
||||
it("needs a change and the admin's own password before Save", async () => {
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
expect(save().hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet
|
||||
await ue.type(screen.getByLabelText("Display name"), "Mia");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends one PATCH with only what changed", async () => {
|
||||
const ue = userEvent.setup();
|
||||
const { onDone } = open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
|
||||
await ue.selectOptions(screen.getByLabelText("Role"), "analyst");
|
||||
await ue.click(screen.getByRole("switch", { name: "Active" }));
|
||||
await ue.click(screen.getByRole("button", { name: "Remove api" }));
|
||||
await ue.selectOptions(screen.getByLabelText("Add workspace"), "7");
|
||||
await ue.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" }));
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(save());
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
2,
|
||||
{
|
||||
email: "mia@x.vn",
|
||||
role: "analyst",
|
||||
disabled: true,
|
||||
addWorkspaces: [7],
|
||||
removeWorkspaces: [5],
|
||||
signOut: true,
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(onDone).toHaveBeenCalledWith("mia updated");
|
||||
});
|
||||
|
||||
it("never lets an admin change their own role or disable themselves", async () => {
|
||||
open({ ...mia, role: "admin" }, true);
|
||||
await screen.findByText("api");
|
||||
expect(screen.getByLabelText("Role").hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByRole("switch", { name: "Active" }).hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a short new password: there is no minimum length", async () => {
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("New password"), "abc");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
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();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.click(screen.getByRole("button", { name: /Generate/ }));
|
||||
expect((screen.getByLabelText("New password") as HTMLInputElement).value).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file useOnScreen.test.tsx
|
||||
* @description useOnScreen follows the IntersectionObserver: on screen by
|
||||
* default (and always without an observer, as in jsdom), off once the element
|
||||
* stops intersecting, and `onShow` runs only on the way back on screen.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useOnScreen, isShown } from "../useOnScreen";
|
||||
|
||||
const original = globalThis.IntersectionObserver;
|
||||
afterEach(() => {
|
||||
globalThis.IntersectionObserver = original;
|
||||
});
|
||||
|
||||
let seen: { current: boolean | null } = { current: null };
|
||||
function Probe({ onShow }: { onShow?: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
seen = useOnScreen(ref, onShow);
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
describe("useOnScreen", () => {
|
||||
it("counts as on screen without an IntersectionObserver", () => {
|
||||
// @ts-expect-error removed for the test
|
||||
delete globalThis.IntersectionObserver;
|
||||
render(<Probe />);
|
||||
expect(seen.current).toBe(true);
|
||||
expect(isShown(seen as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("follows the observer and calls onShow only when it comes back", () => {
|
||||
let fire: (v: boolean) => void = () => {};
|
||||
// @ts-expect-error test stub
|
||||
globalThis.IntersectionObserver = class {
|
||||
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
|
||||
fire = (v) => cb([{ isIntersecting: v }]);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
const onShow = vi.fn();
|
||||
render(<Probe onShow={onShow} />);
|
||||
act(() => fire(true));
|
||||
expect(onShow).not.toHaveBeenCalled();
|
||||
act(() => fire(false));
|
||||
expect(seen.current).toBe(false);
|
||||
expect(isShown(seen as never)).toBe(false);
|
||||
act(() => fire(true));
|
||||
expect(seen.current).toBe(true);
|
||||
expect(onShow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file useOnScreen — whether an element is laid out on screen right now, for
|
||||
* work that only matters while it can be seen. The Workspace keeps panes of
|
||||
* other workspaces and other tabs MOUNTED but `display:none` (unmounting would
|
||||
* drop a live terminal), so `document.visibilityState` alone keeps every hidden
|
||||
* pane polling and re-rendering as if it were shown. An IntersectionObserver
|
||||
* reports a `display:none` subtree as not intersecting; `onShow` runs when the
|
||||
* element comes back, so a caller can catch up once instead of all the time.
|
||||
* Without IntersectionObserver (jsdom, very old browsers) the element counts
|
||||
* as always on screen — the behaviour before this hook existed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
export function useOnScreen(
|
||||
ref: RefObject<Element | null>,
|
||||
onShow?: () => void
|
||||
): RefObject<boolean> {
|
||||
const visible = useRef(true);
|
||||
const showRef = useRef(onShow);
|
||||
showRef.current = onShow;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
const now = entries[entries.length - 1]!.isIntersecting;
|
||||
const was = visible.current;
|
||||
visible.current = now;
|
||||
if (now && !was) showRef.current?.();
|
||||
});
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [ref]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** True while the page is shown AND the element is on screen. */
|
||||
export const isShown = (onScreen: RefObject<boolean>) =>
|
||||
document.visibilityState === "visible" && onScreen.current !== false;
|
||||
@@ -116,6 +116,7 @@ export const i18nReady = i18n
|
||||
"lanes",
|
||||
"users",
|
||||
"onboarding",
|
||||
"artifacts",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"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",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"install": "Install",
|
||||
"installs": "{{count}} installs",
|
||||
"installed": "Installed to {{path}}",
|
||||
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
||||
"rename": "Rename",
|
||||
"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"
|
||||
}
|
||||
@@ -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": "Which CLI drives this workspace. Cannot be changed later.",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} more",
|
||||
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
|
||||
@@ -42,6 +40,26 @@
|
||||
"browse.empty": "This folder is empty.",
|
||||
"browse.fileTitle": "Browse for a file",
|
||||
"browse.select": "Select this folder",
|
||||
"browse.back": "Back (Alt+←)",
|
||||
"browse.create": "Create",
|
||||
"browse.editPath": "Type a path (Ctrl+L)",
|
||||
"browse.filter": "Filter…",
|
||||
"browse.forward": "Forward (Alt+→)",
|
||||
"browse.hint": "Double-click or Enter opens · Backspace goes up · type to jump",
|
||||
"browse.home": "Home",
|
||||
"browse.modified": "Modified",
|
||||
"browse.name": "Name",
|
||||
"browse.newFolder": "New folder",
|
||||
"browse.newFolderName": "Folder name",
|
||||
"browse.noMatch": "Nothing matches the filter.",
|
||||
"browse.places": "Places",
|
||||
"browse.recent": "Recent",
|
||||
"browse.selectFile": "Select file",
|
||||
"browse.showHidden": "Show hidden",
|
||||
"browse.hostFolder": "Server folder",
|
||||
"browse.hostFolderHint": "Browse the server account's own folder ({{path}}). A workspace there runs as the shared server account, which every admin uses — not as your own.",
|
||||
"browse.up": "Up one level (Backspace)",
|
||||
"browse.workspaces": "Workspaces",
|
||||
"browse.title": "Browse for a folder",
|
||||
"changes.back": "All files",
|
||||
"changes.bin": "bin",
|
||||
@@ -64,6 +82,12 @@
|
||||
"changes.expand": "Show the changes panel",
|
||||
"changes.expandAll": "Expand every file",
|
||||
"changes.expandAllFolders": "Expand all folders",
|
||||
"changes.download": "Download",
|
||||
"changes.upload": "Upload files to the workspace root",
|
||||
"changes.uploadHere": "Upload files here…",
|
||||
"changes.uploaded_one": "Uploaded {{count}} file to {{dir}}",
|
||||
"changes.uploaded_other": "Uploaded {{count}} files to {{dir}}",
|
||||
"changes.uploadFailed": "Could not upload {{name}}: {{message}}",
|
||||
"changes.expandContext": "Show more surrounding lines",
|
||||
"changes.expandFile": "Expand {{path}}",
|
||||
"changes.fileGone": "That file no longer has changes in this range.",
|
||||
@@ -166,6 +190,7 @@
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
||||
"gitBar.push": "Push",
|
||||
"gitBar.nothingToPush": "Nothing to push — {{upstream}} already has every commit.",
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Branch",
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
@@ -180,6 +205,21 @@
|
||||
"mode.repoHint": "Adopt this directory as a workspace, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
|
||||
"mode.clone": "Clone",
|
||||
"mode.cloneHint": "Clone a repository over HTTPS or SSH into one of your folders, as a new workspace.",
|
||||
"clone.urlLabel": "Repository URL",
|
||||
"clone.urlHint": "https://host/owner/repo.git, git@host:owner/repo.git or ssh://… — SSH uses your own key (Settings → Git).",
|
||||
"clone.parentLabel": "Clone into folder",
|
||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||
"clone.nameLabel": "Folder name",
|
||||
"clone.cloning": "Cloning…",
|
||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||
"clone.username": "Username",
|
||||
"clone.token": "Password or access token",
|
||||
"clone.remember": "Remember this login for my account",
|
||||
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
|
||||
"clone.sshSettings": "Open Settings → Git",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
@@ -248,6 +288,7 @@
|
||||
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
|
||||
"changes.discardShortConfirm": "Discard?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendPushed": "The last commit is already pushed; amending it would rewrite shared history. Revert it from History instead.",
|
||||
"changes.amendHint": "Fold the ticked files into the last commit (only before it is pushed). Nothing ticked rewords it; leave the message empty to keep the old one.",
|
||||
"changes.amendPlaceholder": "New message (empty = keep the old one)",
|
||||
"changes.historyOf": "History of branch",
|
||||
@@ -324,6 +365,9 @@
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||
"tree.renameSession": "Rename session",
|
||||
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
||||
"tree.pinSession": "Pin to the top of this workspace",
|
||||
"tree.unpinSession": "Unpin",
|
||||
"tree.pinned": "Pinned",
|
||||
"tree.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}",
|
||||
"tree.archiveWorktreeTitle": "Archive and remove its worktree?",
|
||||
"tree.archiveWorktreeBody": "Like Claude Desktop, archiving removes the session's worktree .claude/worktrees/{{name}} and its branch. Git refuses if it holds uncommitted changes, and a branch with commits merged nowhere is kept — nothing unsaved is lost.",
|
||||
@@ -371,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",
|
||||
@@ -403,6 +453,8 @@
|
||||
"sessionMenu.details": "Session details",
|
||||
"sessionMenu.copyId": "Copy session ID",
|
||||
"sessionMenu.archive": "Archive",
|
||||
"sessionMenu.pin": "Pin",
|
||||
"sessionMenu.unpin": "Unpin",
|
||||
"panes.show": "Show {{name}} here",
|
||||
"panes.splitNew.right": "Split right with a new terminal",
|
||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||
@@ -474,5 +526,90 @@
|
||||
"changes.historyWholeBranch": "Whole branch",
|
||||
"changes.annotate": "Annotate",
|
||||
"changes.noBlame": "Nothing to annotate: this file has no committed history yet",
|
||||
"changes.notCommitted": "Not committed yet"
|
||||
"changes.notCommitted": "Not committed yet",
|
||||
"fx.title": "Files",
|
||||
"fx.workspace": "Workspace",
|
||||
"fx.files": "Files",
|
||||
"fx.trash": "Trash",
|
||||
"fx.close": "Close",
|
||||
"fx.up": "Parent folder",
|
||||
"fx.refresh": "Refresh",
|
||||
"fx.newFolder": "New folder",
|
||||
"fx.newFile": "New file",
|
||||
"fx.uploadFiles": "Upload files",
|
||||
"fx.uploadFolder": "Upload a folder",
|
||||
"fx.download": "Download",
|
||||
"fx.downloadZip": "Download as zip",
|
||||
"fx.rename": "Rename",
|
||||
"fx.copy": "Copy",
|
||||
"fx.cut": "Cut",
|
||||
"fx.paste": "Paste",
|
||||
"fx.pasteInto": "Paste into this folder",
|
||||
"fx.pasteFrom": "Paste {{count}} item(s) from {{workspace}}",
|
||||
"fx.delete": "Move to Trash",
|
||||
"fx.open": "Open",
|
||||
"fx.location": "Location",
|
||||
"fx.name": "Name",
|
||||
"fx.size": "Size",
|
||||
"fx.modified": "Modified",
|
||||
"fx.empty": "This folder is empty — drop files here to upload.",
|
||||
"fx.itemsCount": "{{count}} item(s)",
|
||||
"fx.selectedCount": "{{count}} selected",
|
||||
"fx.dropHint": "Drag files or folders here to upload · drag onto a folder to move (Ctrl: copy)",
|
||||
"fx.uploading": "Uploading {{done}}/{{total}}…",
|
||||
"fx.uploaded": "Uploaded {{count}} file(s).",
|
||||
"fx.uploadFailed": "Could not upload {{name}}: {{message}}",
|
||||
"fx.tooBig": "{{size}} is more than one upload may carry ({{max}}). Upload fewer files at a time.",
|
||||
"fx.trashed": "Moved {{count}} item(s) to the Trash.",
|
||||
"fx.copiedDone": "Copied {{count}} item(s) — open a folder (or another workspace) and Paste.",
|
||||
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
|
||||
"fx.pasted": "Pasted.",
|
||||
"fx.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.conflictTitle": "Already there",
|
||||
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
||||
"fx.conflictAll": "Do this for every remaining file",
|
||||
"fx.cancel": "Cancel",
|
||||
"fx.skip": "Skip",
|
||||
"fx.keepBoth": "Keep both",
|
||||
"fx.replace": "Replace",
|
||||
"fx.trashHint": "Deleted files and folders, restorable for 7 days.",
|
||||
"fx.emptyTrash": "Empty Trash",
|
||||
"fx.restore": "Restore",
|
||||
"fx.purge": "Delete forever",
|
||||
"fx.restored": "Restored.",
|
||||
"fx.trashEmpty": "The Trash is empty.",
|
||||
"changes.openExplorer": "Open File Explorer — manage, upload and download files",
|
||||
"changes.openInExplorer": "Open in File Explorer",
|
||||
"fx.places": "Workspaces",
|
||||
"fx.back": "Back",
|
||||
"fx.forward": "Forward",
|
||||
"fx.search": "Search (Ctrl+F)",
|
||||
"fx.searchHere": "Search this folder…",
|
||||
"fx.viewAs": "View as",
|
||||
"fx.gridView": "Icons",
|
||||
"fx.listView": "List",
|
||||
"fx.new": "New",
|
||||
"fx.upload": "Upload",
|
||||
"fx.more": "More",
|
||||
"fx.selectAll": "Select all",
|
||||
"fx.showHidden": "Show hidden files (Ctrl+H)",
|
||||
"fx.untitledFolder": "New folder",
|
||||
"fx.untitledFile": "untitled.txt",
|
||||
"fx.copyPath": "Copy path",
|
||||
"fx.clearSelection": "Clear selection",
|
||||
"fx.noMatch": "Nothing here matches.",
|
||||
"fx.loading": "Loading…",
|
||||
"fx.dropHere": "Drop to upload into {{folder}}",
|
||||
"fx.nameField": "Name",
|
||||
"fx.from": "From",
|
||||
"fx.deletedAt": "Deleted",
|
||||
"fx.unavailable": "The file manager is not available on this dashboard yet — it needs a restart after the update.",
|
||||
"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.readOnly": "Read-only",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"activityFeed": "Activity Feed",
|
||||
"artifacts": "Artifacts",
|
||||
"agents": "Agents",
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
@@ -85,5 +86,6 @@
|
||||
"closeMenu": "Close menu",
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now",
|
||||
"users": "Users"
|
||||
"users": "Users",
|
||||
"files": "Files"
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop files or images to attach",
|
||||
"paste": "Drop to paste into the terminal",
|
||||
"uploading": "Uploading…",
|
||||
"failed": "Could not attach the file",
|
||||
"remove": "Remove attachment",
|
||||
|
||||
@@ -37,29 +37,198 @@
|
||||
"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": "Sagi companion",
|
||||
"label": "{{name}} companion",
|
||||
"live": "Live",
|
||||
"offline": "Offline",
|
||||
"close": "Close Sagi",
|
||||
"close": "Close {{name}}",
|
||||
"statLive": "live",
|
||||
"statWaiting": "waiting",
|
||||
"statErrored": "errored",
|
||||
"runClaude": "Run Claude",
|
||||
"activity": "Activity",
|
||||
"sessions": "Sessions",
|
||||
"errored": "Errored",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"clearAlerts": "Clear alerts",
|
||||
"askPlaceholder": "Ask Sagi… (e.g. any errors?)",
|
||||
"ask": "Ask Sagi",
|
||||
"send": "Send"
|
||||
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
|
||||
"ask": "Ask {{name}}",
|
||||
"send": "Send",
|
||||
"rename": "Click to rename {{name}}",
|
||||
"renameLabel": "Pet name",
|
||||
"renameHint": "Enter to save · empty = default name",
|
||||
"pat": "Pat {{name}}",
|
||||
"attention": "Needs you",
|
||||
"attentionError": "errored",
|
||||
"attentionWaiting": "waiting on you",
|
||||
"attentionMore": "+{{count}} more in Sessions"
|
||||
},
|
||||
"bubbleDismiss": "Dismiss",
|
||||
"open": "Open Sagi companion",
|
||||
"closeAvatar": "Close Sagi",
|
||||
"avatarTitle": "Sagi — Ctrl/⌘+B · drag or arrow keys to move · right-click for pets",
|
||||
"startFailed": "Couldn't start that session: {{error}}"
|
||||
"open": "Open {{name}} companion",
|
||||
"closeAvatar": "Close {{name}}",
|
||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||
"startFailed": "Couldn't start that session: {{error}}",
|
||||
"bubbleOpen": "Open",
|
||||
"bubbleMore": "{{list}} +{{count}} more",
|
||||
"notify": {
|
||||
"session_done_one": "{{count}} session finished ✨",
|
||||
"session_done_other": "{{count}} sessions finished ✨",
|
||||
"session_start_one": "{{count}} new session started 👀",
|
||||
"session_start_other": "{{count}} new sessions started 👀",
|
||||
"subagent_spawn_one": "{{count}} subagent joined in 🤝",
|
||||
"subagent_spawn_other": "{{count}} subagents joined in 🤝",
|
||||
"waiting_one": "{{count}} session is waiting on you ⏳",
|
||||
"waiting_other": "{{count}} sessions are waiting on you ⏳",
|
||||
"error_one": "{{count}} error just popped up 😿",
|
||||
"error_other": "{{count}} errors just popped up 😿",
|
||||
"run_done_one": "{{count}} run finished 🐾",
|
||||
"run_done_other": "{{count}} runs finished 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": [
|
||||
"good morning! ☀️",
|
||||
"morning! coffee first? ☕",
|
||||
"rise and shine 🌤️"
|
||||
],
|
||||
"afternoon": [
|
||||
"good afternoon! 🐾",
|
||||
"hey, welcome back 😺",
|
||||
"afternoon! ready when you are ✨"
|
||||
],
|
||||
"evening": [
|
||||
"good evening! 🌆",
|
||||
"evening shift? I'm in 😼",
|
||||
"hey there, night owl-to-be 🌙"
|
||||
],
|
||||
"night": [
|
||||
"burning the midnight oil? 🌙",
|
||||
"late one, huh? I'll keep watch 🦉",
|
||||
"still up? don't forget to rest 💤"
|
||||
],
|
||||
"summary_one": "{{count}} session live",
|
||||
"summary_other": "{{count}} sessions live",
|
||||
"summaryWaiting": "{{count}} waiting on you",
|
||||
"summaryErrors": "{{count}} errored"
|
||||
},
|
||||
"pat": [
|
||||
"purr… 💜",
|
||||
"hehe, that tickles 😸",
|
||||
"*happy wiggle* ✨",
|
||||
"more pats please 🐾",
|
||||
"best human 😻"
|
||||
],
|
||||
"intent": {
|
||||
"empty": "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
||||
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
||||
"hello": "hi there! 😺 {{summary}}.",
|
||||
"thanks": [
|
||||
"anytime! 💜",
|
||||
"happy to help 😸",
|
||||
"you got it 🐾"
|
||||
],
|
||||
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
||||
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||
"errorsNone": "no errors - all clean 🐾",
|
||||
"waiting_one": "{{count}} session waiting on you{{list}} 👀",
|
||||
"waiting_other": "{{count}} sessions waiting on you{{list}} 👀",
|
||||
"waitingNone": "nothing's waiting on you right now 🐾",
|
||||
"live_one": "{{count}} session live right now 🐾{{tail}}",
|
||||
"live_other": "{{count}} sessions live right now 🐾{{tail}}",
|
||||
"liveTail": " ({{count}} waiting on you 👀)",
|
||||
"liveNone": "nothing's running right now - all quiet.",
|
||||
"status": "{{live}} live · {{waiting}} waiting · {{errors}} errored · {{conn}}.",
|
||||
"connected": "connected",
|
||||
"offline": "offline"
|
||||
},
|
||||
"quips": {
|
||||
"session_done": [
|
||||
"a session just wrapped up! 🐾",
|
||||
"a session finished - nice work! ✨",
|
||||
"that session's all done 😺",
|
||||
"clean finish on that one 💜"
|
||||
],
|
||||
"session_start": [
|
||||
"a new session started! 👀",
|
||||
"a fresh session just landed 🐾",
|
||||
"ooh, a new session to watch 😻",
|
||||
"something new is cooking 🍲"
|
||||
],
|
||||
"subagent_spawn": [
|
||||
"a subagent just spawned! 🐾",
|
||||
"a little helper joined in 🤝",
|
||||
"a subagent's on the job 🚀",
|
||||
"reinforcements - new subagent! 😺"
|
||||
],
|
||||
"waiting": [
|
||||
"a session needs your input 👀",
|
||||
"a session is waiting on you ⏳",
|
||||
"a session paused for your reply 💬",
|
||||
"your turn - a session's waiting 🐾"
|
||||
],
|
||||
"error": [
|
||||
"uh oh, a session hit an error 😿",
|
||||
"something broke - wanna peek? 🙀",
|
||||
"a hook tripped on something ⚠️",
|
||||
"hiss… an error popped up 💢"
|
||||
],
|
||||
"run_done": [
|
||||
"your run just finished! 🐾",
|
||||
"the run's all wrapped up ✨",
|
||||
"run complete - that's a wrap 😸",
|
||||
"all done with that run 💜"
|
||||
],
|
||||
"disconnected": [
|
||||
"lost the connection… 😴",
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤"
|
||||
],
|
||||
"worried": [
|
||||
"that didn't look right 😟",
|
||||
"keeping an eye out 👀",
|
||||
"hmm, something's off 🫣"
|
||||
],
|
||||
"stuck": [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"great run! 😻",
|
||||
"love a tidy finish ✨",
|
||||
"purrfect 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"hmm, let me look… 🤔",
|
||||
"sniffing around… 🐾",
|
||||
"one sec, checking 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"on the prowl 👀",
|
||||
"watching your sessions 😼",
|
||||
"eyes peeled 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"wake me if something happens 😴",
|
||||
"curled up, all calm 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"all quiet 😺",
|
||||
"ready when you are 🐾",
|
||||
"just vibing ✨"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Chat with {{name}}",
|
||||
"back": "Back",
|
||||
"close": "Close {{name}}",
|
||||
"new": "New chat",
|
||||
"openWorkspace": "Open in workspace",
|
||||
"typing": "typing…",
|
||||
"placeholder": "Ask {{name}} anything…",
|
||||
"emptyTitle": "Hi! I'm {{name}} 🐾",
|
||||
"emptyHint": "Ask me anything — I'll get {{tool}} to answer right here.",
|
||||
"starting": "Starting {{tool}}…",
|
||||
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
||||
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"vendor": {
|
||||
@@ -626,6 +795,7 @@
|
||||
"title": "AI Proxy",
|
||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||
"add": "Add profile",
|
||||
"config9router": "Config 9router",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||
@@ -722,6 +892,33 @@
|
||||
"pet": "Pet",
|
||||
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"authorTitle": "Commit author",
|
||||
"authorName": "Name",
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
||||
"authorSaved": "Commit author saved.",
|
||||
"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",
|
||||
"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.",
|
||||
"remove": "Delete",
|
||||
"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; AWS: IAM → Security credentials → SSH public keys — RSA only). Paste the whole line as one line. The private key never leaves the server.",
|
||||
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
|
||||
"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.",
|
||||
"keyType": "Key type",
|
||||
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
||||
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "One login per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
|
||||
@@ -820,6 +1017,10 @@
|
||||
"data": "Data"
|
||||
},
|
||||
"section": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "Your profile, password and sign-ins on this server."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Language, colour mode and theme, and the corner pet."
|
||||
@@ -886,6 +1087,14 @@
|
||||
"agenthub": "AgentHub-managed worktrees",
|
||||
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Text & fonts",
|
||||
"description": "Language, text size and the terminal's font. Saved to your account."
|
||||
},
|
||||
"sessionsView": {
|
||||
"title": "Sessions",
|
||||
"description": "How a new session opens and which sessions the session view's tab bar holds. Saved to your account."
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "The language of the whole dashboard, saved to your account.",
|
||||
@@ -901,9 +1110,20 @@
|
||||
"label": "Tabs from every workspace",
|
||||
"labelDesc": "Keep every workspace's live sessions as tabs, and mix them in one split. Focusing a tab selects its workspace. Off: only the selected workspace's sessions."
|
||||
},
|
||||
"textSize": {
|
||||
"title": "Text size",
|
||||
"description": "How large the dashboard's text is — menus, lists, chat and settings. Saved to your account. Ctrl + / Ctrl − in the browser still zooms everything on top of this.",
|
||||
"label": "Interface text",
|
||||
"names": {
|
||||
"14": "Small",
|
||||
"16": "Default",
|
||||
"17": "Large",
|
||||
"18": "Larger"
|
||||
}
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
|
||||
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -911,7 +1131,8 @@
|
||||
"system": "System font"
|
||||
},
|
||||
"systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"sizeLabel": "Size"
|
||||
},
|
||||
"rowMenu": {
|
||||
"copyHost": "Copy host",
|
||||
@@ -1044,5 +1265,33 @@
|
||||
"chooseFile": "Choose file",
|
||||
"fileTooBig": "{{name}} is {{size}} characters — instructions hold at most {{max}}",
|
||||
"appliesTo": "Applies to"
|
||||
},
|
||||
"account": {
|
||||
"loadFailed": "Could not load your account: {{message}}",
|
||||
"memberSince": "Member since",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"logins": "Active sign-ins",
|
||||
"lastLogin": "Last sign-in",
|
||||
"roleNote": "Your role and access are set by an admin on the Users page.",
|
||||
"profile": "Profile",
|
||||
"profileDescription": "How your name appears to others on this server.",
|
||||
"save": "Save",
|
||||
"profileSaved": "Profile saved",
|
||||
"password": "Password",
|
||||
"passwordDescription": "Changing it keeps this browser signed in and signs you out everywhere else.",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirmPassword": "Confirm new password",
|
||||
"mismatch": "The two new passwords differ.",
|
||||
"changePassword": "Change password",
|
||||
"passwordChanged": "Password changed",
|
||||
"sessionsTitle": "Sign-ins",
|
||||
"sessionsDescription": "You are signed in on {{n}} browser(s), this one included.",
|
||||
"signOutOthers": "Sign out other devices",
|
||||
"signOutOthersConfirm": "Sign out every other device?",
|
||||
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
|
||||
"signedOut_one": "Signed out {{count}} other device",
|
||||
"signedOut_other": "Signed out {{count}} other devices"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"username": "Username",
|
||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||
"password": "Password",
|
||||
"passwordHint": "At least 8 characters",
|
||||
"passwordHint": "No minimum length",
|
||||
"newPassword": "New password",
|
||||
"changeRole": "Change role",
|
||||
"disable": "Disable",
|
||||
@@ -56,7 +56,9 @@
|
||||
"disabled": "{{name}} disabled",
|
||||
"enabled": "{{name}} enabled",
|
||||
"password": "{{name}}'s password reset",
|
||||
"workspace": "Workspace given to {{name}}"
|
||||
"workspace": "Workspace given to {{name}}",
|
||||
"updated": "{{name}} updated",
|
||||
"deleted": "{{name}} was deleted."
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
@@ -64,12 +66,135 @@
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
"EEXISTS": "That username is taken.",
|
||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||
"EBADPASSWORD": "Password: at least 8 characters."
|
||||
"EBADPASSWORD": "Password cannot be empty.",
|
||||
"EBADEMAIL": "That email address does not look valid.",
|
||||
"EBADFIELD": "One of the values is not valid.",
|
||||
"ENOLANE": "That workspace no longer exists.",
|
||||
"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.",
|
||||
"ELASTADMIN": "The last admin cannot be deleted."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
||||
"auditEmpty": "Nothing recorded yet.",
|
||||
"activeCount": "{{n}} active",
|
||||
"cost30d": "Cost · 30 days"
|
||||
"cost30d": "Cost · 30 days",
|
||||
"displayName": "Display name",
|
||||
"displayNameHint": "Shown beside the username. Optional.",
|
||||
"email": "Email",
|
||||
"edit": {
|
||||
"button": "Edit user",
|
||||
"title": "Edit {{name}}",
|
||||
"created": "Created",
|
||||
"lastLogin": "Last sign-in",
|
||||
"never": "Never",
|
||||
"logins": "Active sign-ins",
|
||||
"profile": "Profile",
|
||||
"access": "Access",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
|
||||
"selfRole": "You cannot change your own role.",
|
||||
"selfDisable": "You cannot disable yourself.",
|
||||
"password": "Password",
|
||||
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere.",
|
||||
"show": "Show password",
|
||||
"hide": "Hide password",
|
||||
"copy": "Copy password",
|
||||
"generate": "Generate",
|
||||
"loading": "Loading…",
|
||||
"noOwned": "Owns no workspace.",
|
||||
"undo": "Undo",
|
||||
"removeWorkspace": "Remove {{name}}",
|
||||
"removeWorkspaceHint": "The workspace is left with no owner",
|
||||
"addWorkspace": "Add workspace",
|
||||
"chooseWorkspace": "Choose a workspace…",
|
||||
"sessions": "Sign-ins",
|
||||
"signOut": "Sign out everywhere",
|
||||
"signOutHint": "Ends this user's {{n}} active sign-in(s); they sign in again with their password.",
|
||||
"noChanges": "No changes yet."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
"description": "Claude and Codex accounts every user's new sessions run on — the least-used one is picked, and a login is written into the user's home by the server, so nobody signs in per user.",
|
||||
"refresh": "Re-read quotas",
|
||||
"add": "Add account",
|
||||
"empty": "No accounts yet. Until you add one, each user's session needs that user's own sign-in.",
|
||||
"account": "Account",
|
||||
"tool": "Tool",
|
||||
"livePanes": "Live",
|
||||
"quota": "Quota",
|
||||
"spend": "5 hours",
|
||||
"spend7d": "{{cost}} in 7 days",
|
||||
"added": "added {{at}}",
|
||||
"expired": "expired",
|
||||
"full": "full",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Name",
|
||||
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
||||
"remove": "Remove {{name}}",
|
||||
"removeHint": "New sessions stop using this account. Sessions already running on it keep running.",
|
||||
"removeButton": "Remove",
|
||||
"done": {
|
||||
"added": "{{name}} added to the pool",
|
||||
"removed": "{{name}} removed from the pool"
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||
"EBADNAME": "Name: 1–64 letters, digits, '.', '_' or '-'.",
|
||||
"EBADTOKEN": "That is not a valid token for this tool.",
|
||||
"EEXISTS": "An account with that name is already in the pool.",
|
||||
"ENOACCOUNT": "That account is no longer in the pool.",
|
||||
"ELOCALMODE": "In local mode the pool is changed from the host's shell (agenthub accounts).",
|
||||
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
||||
"ENOTFOUND": "That saved login no longer exists.",
|
||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||
"EUPSTREAM": "Anthropic could not be reached. Try again."
|
||||
},
|
||||
"source": "From",
|
||||
"sources": {
|
||||
"profile": "A saved login (Switch account profiles)",
|
||||
"credentials": "Paste .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Saved in Settings → Claude config → Switch account. The one this server's own Claude Code is logged into can be moved too: the server keeps running on it and refreshes it for everyone.",
|
||||
"chooseProfile": "Choose a saved login…",
|
||||
"noProfiles": "No saved logins — save one in Settings → Claude config → Switch account",
|
||||
"loadingProfiles": "Loading…",
|
||||
"activeHere": "in use on this server — keeps working",
|
||||
"nameFromProfile": "Leave blank to use the profile's name",
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key or auth.json"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"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ở",
|
||||
"preview": "Xem",
|
||||
"download": "Tải về",
|
||||
"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": "Đổi tên",
|
||||
"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"
|
||||
}
|
||||
@@ -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": "CLI nào điều khiển workspace này. Không thể thay đổi sau này.",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} nữa",
|
||||
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
|
||||
@@ -42,6 +40,26 @@
|
||||
"browse.empty": "Thư mục này trống.",
|
||||
"browse.fileTitle": "Duyệt chọn tệp",
|
||||
"browse.select": "Chọn thư mục này",
|
||||
"browse.back": "Quay lại (Alt+←)",
|
||||
"browse.create": "Tạo",
|
||||
"browse.editPath": "Gõ đường dẫn (Ctrl+L)",
|
||||
"browse.filter": "Lọc…",
|
||||
"browse.forward": "Tiến (Alt+→)",
|
||||
"browse.hint": "Nháy đúp hoặc Enter để mở · Backspace để lên · gõ chữ để nhảy tới",
|
||||
"browse.home": "Thư mục chính",
|
||||
"browse.modified": "Ngày sửa",
|
||||
"browse.name": "Tên",
|
||||
"browse.newFolder": "Thư mục mới",
|
||||
"browse.newFolderName": "Tên thư mục",
|
||||
"browse.noMatch": "Không có mục nào khớp bộ lọc.",
|
||||
"browse.places": "Vị trí",
|
||||
"browse.recent": "Gần đây",
|
||||
"browse.selectFile": "Chọn tệp",
|
||||
"browse.showHidden": "Hiện mục ẩn",
|
||||
"browse.hostFolder": "Thư mục máy chủ",
|
||||
"browse.hostFolderHint": "Duyệt thư mục của tài khoản máy chủ ({{path}}). Workspace ở đó chạy bằng tài khoản máy chủ dùng chung cho mọi admin — không phải tài khoản riêng của bạn.",
|
||||
"browse.up": "Lên một cấp (Backspace)",
|
||||
"browse.workspaces": "Workspace",
|
||||
"browse.title": "Duyệt chọn thư mục",
|
||||
"changes.back": "Tất cả file",
|
||||
"changes.bin": "bin",
|
||||
@@ -64,6 +82,12 @@
|
||||
"changes.expand": "Mở bảng thay đổi",
|
||||
"changes.expandAll": "Mở tất cả file",
|
||||
"changes.expandAllFolders": "Mở tất cả thư mục",
|
||||
"changes.download": "Tải xuống",
|
||||
"changes.upload": "Tải file lên thư mục gốc của workspace",
|
||||
"changes.uploadHere": "Tải file lên đây…",
|
||||
"changes.uploaded_one": "Đã tải {{count}} file lên {{dir}}",
|
||||
"changes.uploaded_other": "Đã tải {{count}} file lên {{dir}}",
|
||||
"changes.uploadFailed": "Không tải lên được {{name}}: {{message}}",
|
||||
"changes.expandContext": "Hiện thêm dòng xung quanh",
|
||||
"changes.expandFile": "Mở {{path}}",
|
||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||
@@ -166,6 +190,7 @@
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
||||
"gitBar.push": "Push",
|
||||
"gitBar.nothingToPush": "Không có gì để push — {{upstream}} đã có mọi commit.",
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Nhánh",
|
||||
"gitBar.switchApply": "Chuyển nhánh",
|
||||
@@ -180,6 +205,21 @@
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm workspace, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
|
||||
"mode.clone": "Clone",
|
||||
"mode.cloneHint": "Clone một repository qua HTTPS hoặc SSH vào thư mục của bạn, thành workspace mới.",
|
||||
"clone.urlLabel": "URL repository",
|
||||
"clone.urlHint": "https://host/owner/repo.git, git@host:owner/repo.git hoặc ssh://… — SSH dùng key riêng của bạn (Cài đặt → Git).",
|
||||
"clone.parentLabel": "Clone vào thư mục",
|
||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||
"clone.nameLabel": "Tên thư mục",
|
||||
"clone.cloning": "Đang clone…",
|
||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||
"clone.username": "Username",
|
||||
"clone.token": "Mật khẩu hoặc access token",
|
||||
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
||||
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
|
||||
"clone.sshSettings": "Mở Cài đặt → Git",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
@@ -248,6 +288,7 @@
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
|
||||
"changes.discardShortConfirm": "Xoá?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendPushed": "Commit cuối đã được push; amend sẽ viết lại lịch sử đã chia sẻ. Hãy revert nó trong History.",
|
||||
"changes.amendHint": "Gộp các file đã tick vào commit cuối (chỉ khi chưa push). Không tick file nào thì chỉ sửa message; để trống message để giữ message cũ.",
|
||||
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
|
||||
"changes.historyOf": "Lịch sử của nhánh",
|
||||
@@ -324,6 +365,9 @@
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||
"tree.renameSession": "Đổi tên session",
|
||||
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
|
||||
"tree.pinSession": "Ghim lên đầu workspace này",
|
||||
"tree.unpinSession": "Bỏ ghim",
|
||||
"tree.pinned": "Đã ghim",
|
||||
"tree.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}",
|
||||
"tree.archiveWorktreeTitle": "Lưu trữ và gỡ worktree của session?",
|
||||
"tree.archiveWorktreeBody": "Giống Claude Desktop, lưu trữ sẽ gỡ worktree .claude/worktrees/{{name}} của session cùng branch của nó. Git sẽ từ chối nếu còn thay đổi chưa commit, và branch có commit chưa merge vào đâu sẽ được giữ lại — không mất gì chưa lưu.",
|
||||
@@ -371,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",
|
||||
@@ -403,6 +453,8 @@
|
||||
"sessionMenu.details": "Chi tiết session",
|
||||
"sessionMenu.copyId": "Sao chép ID session",
|
||||
"sessionMenu.archive": "Lưu trữ",
|
||||
"sessionMenu.pin": "Ghim",
|
||||
"sessionMenu.unpin": "Bỏ ghim",
|
||||
"panes.show": "Hiện {{name}} ở đây",
|
||||
"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",
|
||||
@@ -474,5 +526,90 @@
|
||||
"changes.historyWholeBranch": "Cả nhánh",
|
||||
"changes.annotate": "Annotate",
|
||||
"changes.noBlame": "Không có gì để annotate: file này chưa có lịch sử commit",
|
||||
"changes.notCommitted": "Chưa commit"
|
||||
"changes.notCommitted": "Chưa commit",
|
||||
"fx.title": "Quản lý file",
|
||||
"fx.workspace": "Workspace",
|
||||
"fx.files": "File",
|
||||
"fx.trash": "Thùng rác",
|
||||
"fx.close": "Đóng",
|
||||
"fx.up": "Thư mục cha",
|
||||
"fx.refresh": "Làm mới",
|
||||
"fx.newFolder": "Thư mục mới",
|
||||
"fx.newFile": "File mới",
|
||||
"fx.uploadFiles": "Tải file lên",
|
||||
"fx.uploadFolder": "Tải cả thư mục lên",
|
||||
"fx.download": "Tải về",
|
||||
"fx.downloadZip": "Tải về dạng zip",
|
||||
"fx.rename": "Đổi tên",
|
||||
"fx.copy": "Sao chép",
|
||||
"fx.cut": "Cắt",
|
||||
"fx.paste": "Dán",
|
||||
"fx.pasteInto": "Dán vào thư mục này",
|
||||
"fx.pasteFrom": "Dán {{count}} mục từ {{workspace}}",
|
||||
"fx.delete": "Chuyển vào Thùng rác",
|
||||
"fx.open": "Mở",
|
||||
"fx.location": "Vị trí",
|
||||
"fx.name": "Tên",
|
||||
"fx.size": "Dung lượng",
|
||||
"fx.modified": "Sửa lần cuối",
|
||||
"fx.empty": "Thư mục trống — thả file vào đây để tải lên.",
|
||||
"fx.itemsCount": "{{count}} mục",
|
||||
"fx.selectedCount": "Đã chọn {{count}}",
|
||||
"fx.dropHint": "Kéo file hoặc thư mục vào đây để tải lên · kéo vào một thư mục để di chuyển (Ctrl: sao chép)",
|
||||
"fx.uploading": "Đang tải lên {{done}}/{{total}}…",
|
||||
"fx.uploaded": "Đã tải lên {{count}} file.",
|
||||
"fx.uploadFailed": "Không tải lên được {{name}}: {{message}}",
|
||||
"fx.tooBig": "{{size}} vượt quá giới hạn một lần tải lên ({{max}}). Hãy tải ít file hơn mỗi lần.",
|
||||
"fx.trashed": "Đã chuyển {{count}} mục vào Thùng rác.",
|
||||
"fx.copiedDone": "Đã sao chép {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
|
||||
"fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
|
||||
"fx.pasted": "Đã dán.",
|
||||
"fx.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.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",
|
||||
"fx.cancel": "Huỷ",
|
||||
"fx.skip": "Bỏ qua",
|
||||
"fx.keepBoth": "Giữ cả hai",
|
||||
"fx.replace": "Thay thế",
|
||||
"fx.trashHint": "File và thư mục đã xoá, khôi phục được trong 7 ngày.",
|
||||
"fx.emptyTrash": "Dọn sạch Thùng rác",
|
||||
"fx.restore": "Khôi phục",
|
||||
"fx.purge": "Xoá vĩnh viễn",
|
||||
"fx.restored": "Đã khôi phục.",
|
||||
"fx.trashEmpty": "Thùng rác trống.",
|
||||
"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",
|
||||
"fx.back": "Quay lại",
|
||||
"fx.forward": "Tiến tới",
|
||||
"fx.search": "Tìm (Ctrl+F)",
|
||||
"fx.searchHere": "Tìm trong thư mục này…",
|
||||
"fx.viewAs": "Kiểu xem",
|
||||
"fx.gridView": "Biểu tượng",
|
||||
"fx.listView": "Danh sách",
|
||||
"fx.new": "Mới",
|
||||
"fx.upload": "Tải lên",
|
||||
"fx.more": "Thêm",
|
||||
"fx.selectAll": "Chọn tất cả",
|
||||
"fx.showHidden": "Hiện file ẩn (Ctrl+H)",
|
||||
"fx.untitledFolder": "Thư mục mới",
|
||||
"fx.untitledFile": "chua-dat-ten.txt",
|
||||
"fx.copyPath": "Sao chép đường dẫn",
|
||||
"fx.clearSelection": "Bỏ chọn",
|
||||
"fx.noMatch": "Không có mục nào khớp.",
|
||||
"fx.loading": "Đang tải…",
|
||||
"fx.dropHere": "Thả để tải lên vào {{folder}}",
|
||||
"fx.nameField": "Tên",
|
||||
"fx.from": "Từ",
|
||||
"fx.deletedAt": "Ngày xoá",
|
||||
"fx.unavailable": "Dashboard này chưa có trình quản lý file — cần khởi động lại sau khi cập nhật.",
|
||||
"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.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.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."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"activityFeed": "Luồng hoạt động",
|
||||
"artifacts": "Artifacts",
|
||||
"agents": "Agent",
|
||||
"analytics": "Phân tích",
|
||||
"avg": "TB",
|
||||
@@ -85,5 +86,6 @@
|
||||
"closeMenu": "Đóng menu",
|
||||
"sidebar": "Thanh bên",
|
||||
"now": "bây giờ",
|
||||
"users": "Người dùng"
|
||||
"users": "Người dùng",
|
||||
"files": "Quản lý file"
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả file hoặc ảnh để đính kèm",
|
||||
"paste": "Thả để dán vào terminal",
|
||||
"uploading": "Đang tải lên…",
|
||||
"failed": "Không đính kèm được file",
|
||||
"remove": "Bỏ đính kèm",
|
||||
|
||||
@@ -37,29 +37,198 @@
|
||||
"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ý Sagi",
|
||||
"label": "Trợ lý {{name}}",
|
||||
"live": "Trực tuyến",
|
||||
"offline": "Ngoại tuyến",
|
||||
"close": "Đóng Sagi",
|
||||
"close": "Đóng {{name}}",
|
||||
"statLive": "đang chạy",
|
||||
"statWaiting": "đang chờ",
|
||||
"statErrored": "lỗi",
|
||||
"runClaude": "Chạy Claude",
|
||||
"activity": "Hoạt động",
|
||||
"sessions": "Phiên",
|
||||
"errored": "Phiên lỗi",
|
||||
"mute": "Tắt tiếng",
|
||||
"unmute": "Bật tiếng",
|
||||
"clearAlerts": "Xoá cảnh báo",
|
||||
"askPlaceholder": "Hỏi Sagi… (vd: có lỗi nào không?)",
|
||||
"ask": "Hỏi Sagi",
|
||||
"send": "Gửi"
|
||||
"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",
|
||||
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
||||
"pat": "Xoa đầu {{name}}",
|
||||
"attention": "Cần bạn xem",
|
||||
"attentionError": "bị lỗi",
|
||||
"attentionWaiting": "đang chờ bạn",
|
||||
"attentionMore": "+{{count}} phiên khác trong Phiên"
|
||||
},
|
||||
"bubbleDismiss": "Đóng",
|
||||
"open": "Mở trợ lý Sagi",
|
||||
"closeAvatar": "Đóng Sagi",
|
||||
"avatarTitle": "Sagi — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · chuột phải để chọn pet",
|
||||
"startFailed": "Không khởi động được phiên: {{error}}"
|
||||
"open": "Mở trợ lý {{name}}",
|
||||
"closeAvatar": "Đóng {{name}}",
|
||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
||||
"startFailed": "Không khởi động được phiên: {{error}}",
|
||||
"bubbleOpen": "Mở",
|
||||
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
||||
"notify": {
|
||||
"session_done_one": "{{count}} phiên vừa xong ✨",
|
||||
"session_done_other": "{{count}} phiên vừa xong ✨",
|
||||
"session_start_one": "{{count}} phiên mới vừa bắt đầu 👀",
|
||||
"session_start_other": "{{count}} phiên mới vừa bắt đầu 👀",
|
||||
"subagent_spawn_one": "{{count}} subagent vừa vào việc 🤝",
|
||||
"subagent_spawn_other": "{{count}} subagent vừa vào việc 🤝",
|
||||
"waiting_one": "{{count}} phiên đang chờ bạn ⏳",
|
||||
"waiting_other": "{{count}} phiên đang chờ bạn ⏳",
|
||||
"error_one": "Vừa có {{count}} lỗi 😿",
|
||||
"error_other": "Vừa có {{count}} lỗi 😿",
|
||||
"run_done_one": "{{count}} lượt chạy vừa xong 🐾",
|
||||
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": [
|
||||
"chào buổi sáng! ☀️",
|
||||
"sáng rồi! cà phê chưa? ☕",
|
||||
"ngày mới tốt lành nha 🌤️"
|
||||
],
|
||||
"afternoon": [
|
||||
"chào buổi chiều! 🐾",
|
||||
"bạn quay lại rồi 😺",
|
||||
"chiều nay làm gì đây? ✨"
|
||||
],
|
||||
"evening": [
|
||||
"chào buổi tối! 🌆",
|
||||
"ca tối hả? có mình đây 😼",
|
||||
"tối rồi, cố lên nhé 🌙"
|
||||
],
|
||||
"night": [
|
||||
"khuya rồi mà vẫn làm à? 🌙",
|
||||
"để mình canh giúp cho 🦉",
|
||||
"nhớ nghỉ ngơi nha 💤"
|
||||
],
|
||||
"summary_one": "{{count}} phiên đang chạy",
|
||||
"summary_other": "{{count}} phiên đang chạy",
|
||||
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||
"summaryErrors": "{{count}} phiên lỗi"
|
||||
},
|
||||
"pat": [
|
||||
"grừ grừ… 💜",
|
||||
"hihi, nhột quá 😸",
|
||||
"*vẫy đuôi* ✨",
|
||||
"xoa nữa đi mà 🐾",
|
||||
"chủ nhân tuyệt nhất 😻"
|
||||
],
|
||||
"intent": {
|
||||
"empty": "hỏi mình về các phiên nhé - đang chạy gì, có lỗi không, hay tình hình chung.",
|
||||
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
||||
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
||||
"hello": "chào bạn! 😺 {{summary}}.",
|
||||
"thanks": [
|
||||
"có gì đâu! 💜",
|
||||
"rất vui được giúp 😸",
|
||||
"chuyện nhỏ 🐾"
|
||||
],
|
||||
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||
"waiting_one": "{{count}} phiên đang chờ bạn{{list}} 👀",
|
||||
"waiting_other": "{{count}} phiên đang chờ bạn{{list}} 👀",
|
||||
"waitingNone": "hiện không có gì chờ bạn cả 🐾",
|
||||
"live_one": "{{count}} phiên đang chạy 🐾{{tail}}",
|
||||
"live_other": "{{count}} phiên đang chạy 🐾{{tail}}",
|
||||
"liveTail": " ({{count}} phiên chờ bạn 👀)",
|
||||
"liveNone": "hiện không có gì đang chạy - yên ắng.",
|
||||
"status": "{{live}} đang chạy · {{waiting}} đang chờ · {{errors}} lỗi · {{conn}}.",
|
||||
"connected": "đã kết nối",
|
||||
"offline": "mất kết nối"
|
||||
},
|
||||
"quips": {
|
||||
"session_done": [
|
||||
"một phiên vừa xong! 🐾",
|
||||
"xong một phiên rồi - giỏi lắm! ✨",
|
||||
"phiên đó xong xuôi rồi 😺",
|
||||
"kết thúc gọn gàng 💜"
|
||||
],
|
||||
"session_start": [
|
||||
"có phiên mới bắt đầu! 👀",
|
||||
"một phiên mới vừa tới 🐾",
|
||||
"ồ, có phiên mới để canh 😻",
|
||||
"có gì đó đang được nấu 🍲"
|
||||
],
|
||||
"subagent_spawn": [
|
||||
"một subagent vừa xuất hiện! 🐾",
|
||||
"có trợ thủ nhỏ vào cuộc 🤝",
|
||||
"subagent đã nhận việc 🚀",
|
||||
"viện binh tới - subagent mới! 😺"
|
||||
],
|
||||
"waiting": [
|
||||
"một phiên cần bạn trả lời 👀",
|
||||
"có phiên đang chờ bạn ⏳",
|
||||
"một phiên tạm dừng chờ bạn 💬",
|
||||
"tới lượt bạn - có phiên đang chờ 🐾"
|
||||
],
|
||||
"error": [
|
||||
"ối, một phiên bị lỗi 😿",
|
||||
"có gì đó hỏng rồi - xem thử không? 🙀",
|
||||
"một hook vừa vấp phải lỗi ⚠️",
|
||||
"khè… lỗi xuất hiện 💢"
|
||||
],
|
||||
"run_done": [
|
||||
"lượt chạy của bạn xong rồi! 🐾",
|
||||
"lượt chạy đã hoàn tất ✨",
|
||||
"chạy xong - hết phim 😸",
|
||||
"xong lượt chạy đó rồi 💜"
|
||||
],
|
||||
"disconnected": [
|
||||
"mất kết nối rồi… 😴",
|
||||
"không gọi được máy chủ 📡",
|
||||
"mất sóng - đi ngủ chút 💤"
|
||||
],
|
||||
"worried": [
|
||||
"có vẻ không ổn 😟",
|
||||
"mình đang để mắt đây 👀",
|
||||
"hừm, có gì đó lạ lạ 🫣"
|
||||
],
|
||||
"stuck": [
|
||||
"một phiên im lặng lâu rồi… 🤔",
|
||||
"có gì bị kẹt không? ⏳",
|
||||
"vẫn đang nhai… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"tuyệt vời! 😻",
|
||||
"kết thúc gọn ghê ✨",
|
||||
"hoàn hảo 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"để mình xem… 🤔",
|
||||
"đang đánh hơi… 🐾",
|
||||
"chờ chút, đang kiểm tra 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"đang tuần tra 👀",
|
||||
"đang canh các phiên của bạn 😼",
|
||||
"mắt mở to 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"có gì thì gọi mình dậy nhé 😴",
|
||||
"cuộn tròn, mọi thứ yên ổn 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"yên ắng quá 😺",
|
||||
"sẵn sàng khi bạn cần 🐾",
|
||||
"chill thôi ✨"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Trò chuyện với {{name}}",
|
||||
"back": "Quay lại",
|
||||
"close": "Đóng {{name}}",
|
||||
"new": "Cuộc trò chuyện mới",
|
||||
"openWorkspace": "Mở trong workspace",
|
||||
"typing": "đang trả lời…",
|
||||
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
||||
"emptyTitle": "Chào bạn! Mình là {{name}} 🐾",
|
||||
"emptyHint": "Hỏi gì cũng được — mình sẽ nhờ {{tool}} trả lời ngay tại đây.",
|
||||
"starting": "Đang khởi động {{tool}}…",
|
||||
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
|
||||
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"vendor": {
|
||||
@@ -626,6 +795,7 @@
|
||||
"title": "AI Proxy",
|
||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||
"add": "Thêm profile",
|
||||
"config9router": "Config 9router",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||
@@ -722,6 +892,33 @@
|
||||
"pet": "Pet",
|
||||
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"authorTitle": "Tác giả commit",
|
||||
"authorName": "Tên",
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
||||
"authorSaved": "Đã lưu tác giả commit.",
|
||||
"title": "Danh tính git của bạn",
|
||||
"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",
|
||||
"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.",
|
||||
"remove": "Xoá",
|
||||
"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; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.",
|
||||
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
|
||||
"httpsTitle": "Đăng nhập HTTPS",
|
||||
"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.",
|
||||
"keyType": "Loại key",
|
||||
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
||||
"keyRsa": "RSA 4096 (AWS CodeCommit, server cũ)"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
|
||||
@@ -820,6 +1017,10 @@
|
||||
"data": "Dữ liệu"
|
||||
},
|
||||
"section": {
|
||||
"account": {
|
||||
"title": "Tài khoản",
|
||||
"description": "Hồ sơ, mật khẩu và các phiên đăng nhập của bạn trên server này."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
|
||||
@@ -886,6 +1087,14 @@
|
||||
"agenthub": "Worktree do AgentHub quản lý",
|
||||
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Chữ & font",
|
||||
"description": "Ngôn ngữ, cỡ chữ và font của terminal. Lưu theo tài khoản của bạn."
|
||||
},
|
||||
"sessionsView": {
|
||||
"title": "Phiên làm việc",
|
||||
"description": "Cách mở một session mới và những session nào nằm trên thanh tab. Lưu theo tài khoản của bạn."
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.",
|
||||
@@ -901,9 +1110,20 @@
|
||||
"label": "Tab của mọi workspace",
|
||||
"labelDesc": "Giữ session đang chạy của mọi workspace thành tab, và trộn chúng trong một tab chia đôi. Chọn một tab sẽ chuyển sang workspace của nó. Tắt: chỉ session của workspace đang chọn."
|
||||
},
|
||||
"textSize": {
|
||||
"title": "Cỡ chữ",
|
||||
"description": "Độ lớn chữ của dashboard — menu, danh sách, chat và cài đặt. Lưu vào tài khoản của bạn. Ctrl + / Ctrl − của trình duyệt vẫn phóng to toàn bộ thêm trên mức này.",
|
||||
"label": "Chữ giao diện",
|
||||
"names": {
|
||||
"14": "Nhỏ",
|
||||
"16": "Mặc định",
|
||||
"17": "Lớn",
|
||||
"18": "Rất lớn"
|
||||
}
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
|
||||
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -911,7 +1131,8 @@
|
||||
"system": "Font hệ thống"
|
||||
},
|
||||
"systemNote": "Font độ rộng cố định của trình duyệt. Font này có thể thiếu một số chữ tiếng Việt (ấ, ế, ố…), khi đó chữ sẽ bị lệch ô.",
|
||||
"preview": "Xem trước"
|
||||
"preview": "Xem trước",
|
||||
"sizeLabel": "Cỡ chữ"
|
||||
},
|
||||
"rowMenu": {
|
||||
"copyHost": "Sao chép host",
|
||||
@@ -1044,5 +1265,33 @@
|
||||
"chooseFile": "Chọn file",
|
||||
"fileTooBig": "{{name}} dài {{size}} ký tự — hướng dẫn tối đa {{max}}",
|
||||
"appliesTo": "Áp dụng cho"
|
||||
},
|
||||
"account": {
|
||||
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||
"memberSince": "Tham gia từ",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Session",
|
||||
"logins": "Phiên đăng nhập",
|
||||
"lastLogin": "Đăng nhập gần nhất",
|
||||
"roleNote": "Vai trò và quyền truy cập của bạn do admin đặt ở trang Người dùng.",
|
||||
"profile": "Hồ sơ",
|
||||
"profileDescription": "Tên của bạn hiển thị với người khác trên server này.",
|
||||
"save": "Lưu",
|
||||
"profileSaved": "Đã lưu hồ sơ",
|
||||
"password": "Mật khẩu",
|
||||
"passwordDescription": "Đổi mật khẩu giữ trình duyệt này đăng nhập và đăng xuất bạn ở mọi nơi khác.",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"confirmPassword": "Nhập lại mật khẩu mới",
|
||||
"mismatch": "Hai mật khẩu mới không khớp.",
|
||||
"changePassword": "Đổi mật khẩu",
|
||||
"passwordChanged": "Đã đổi mật khẩu",
|
||||
"sessionsTitle": "Phiên đăng nhập",
|
||||
"sessionsDescription": "Bạn đang đăng nhập trên {{n}} trình duyệt, tính cả trình duyệt này.",
|
||||
"signOutOthers": "Đăng xuất các thiết bị khác",
|
||||
"signOutOthersConfirm": "Đăng xuất mọi thiết bị khác?",
|
||||
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
|
||||
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
|
||||
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Ít nhất 8 ký tự",
|
||||
"passwordHint": "Không giới hạn độ dài tối thiểu",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"changeRole": "Đổi vai trò",
|
||||
"disable": "Khoá",
|
||||
@@ -56,7 +56,9 @@
|
||||
"disabled": "Đã khoá {{name}}",
|
||||
"enabled": "Đã mở khoá {{name}}",
|
||||
"password": "Đã đặt lại mật khẩu cho {{name}}",
|
||||
"workspace": "Đã giao workspace cho {{name}}"
|
||||
"workspace": "Đã giao workspace cho {{name}}",
|
||||
"updated": "Đã cập nhật {{name}}",
|
||||
"deleted": "Đã xoá {{name}}."
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||
@@ -64,12 +66,135 @@
|
||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
|
||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||
"EBADPASSWORD": "Mật khẩu: ít nhất 8 ký tự."
|
||||
"EBADPASSWORD": "Mật khẩu không được để trống.",
|
||||
"EBADEMAIL": "Địa chỉ email không hợp lệ.",
|
||||
"EBADFIELD": "Có một giá trị không hợp lệ.",
|
||||
"ENOLANE": "Workspace đó không còn tồn tại.",
|
||||
"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.",
|
||||
"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",
|
||||
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
|
||||
"auditEmpty": "Chưa ghi nhận gì.",
|
||||
"activeCount": "{{n}} đang hoạt động",
|
||||
"cost30d": "Chi phí · 30 ngày"
|
||||
"cost30d": "Chi phí · 30 ngày",
|
||||
"displayName": "Tên hiển thị",
|
||||
"displayNameHint": "Hiện cạnh tên đăng nhập. Không bắt buộc.",
|
||||
"email": "Email",
|
||||
"edit": {
|
||||
"button": "Sửa người dùng",
|
||||
"title": "Sửa {{name}}",
|
||||
"created": "Ngày tạo",
|
||||
"lastLogin": "Đăng nhập gần nhất",
|
||||
"never": "Chưa bao giờ",
|
||||
"logins": "Phiên đăng nhập",
|
||||
"profile": "Hồ sơ",
|
||||
"access": "Quyền truy cập",
|
||||
"status": "Trạng thái",
|
||||
"active": "Đang hoạt động",
|
||||
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
|
||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi.",
|
||||
"show": "Hiện mật khẩu",
|
||||
"hide": "Ẩn mật khẩu",
|
||||
"copy": "Sao chép mật khẩu",
|
||||
"generate": "Tạo ngẫu nhiên",
|
||||
"loading": "Đang tải…",
|
||||
"noOwned": "Chưa sở hữu workspace nào.",
|
||||
"undo": "Hoàn tác",
|
||||
"removeWorkspace": "Bỏ {{name}}",
|
||||
"removeWorkspaceHint": "Workspace sẽ không còn chủ sở hữu",
|
||||
"addWorkspace": "Thêm workspace",
|
||||
"chooseWorkspace": "Chọn một workspace…",
|
||||
"sessions": "Phiên đăng nhập",
|
||||
"signOut": "Đăng xuất ở mọi nơi",
|
||||
"signOutHint": "Kết thúc {{n}} phiên đăng nhập đang hoạt động của người này; họ đăng nhập lại bằng mật khẩu.",
|
||||
"noChanges": "Chưa có thay đổi."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
"description": "Các tài khoản Claude và Codex mà phiên mới của mọi user chạy trên đó — tự chọn tài khoản ít tải nhất, server ghi login vào home của user, không ai phải đăng nhập riêng.",
|
||||
"refresh": "Đọc lại quota",
|
||||
"add": "Thêm tài khoản",
|
||||
"empty": "Chưa có tài khoản nào. Khi chưa thêm, phiên của mỗi người dùng cần chính người đó đăng nhập.",
|
||||
"account": "Tài khoản",
|
||||
"tool": "Công cụ",
|
||||
"livePanes": "Đang chạy",
|
||||
"quota": "Quota",
|
||||
"spend": "5 giờ",
|
||||
"spend7d": "{{cost}} trong 7 ngày",
|
||||
"added": "thêm lúc {{at}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "đầy",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Tên",
|
||||
"nameHint": "Nhãn tuỳ ý để bạn nhận ra: chữ, số, '.', '_' hoặc '-'",
|
||||
"remove": "Xoá {{name}}",
|
||||
"removeHint": "Phiên mới sẽ không dùng tài khoản này nữa. Các phiên đang chạy trên nó vẫn chạy tiếp.",
|
||||
"removeButton": "Xoá",
|
||||
"done": {
|
||||
"added": "Đã thêm {{name}} vào pool",
|
||||
"removed": "Đã xoá {{name}} khỏi pool"
|
||||
},
|
||||
"errors": {
|
||||
"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.",
|
||||
"EBADNAME": "Tên: 1–64 ký tự chữ, số, '.', '_' hoặc '-'.",
|
||||
"EBADTOKEN": "Token không hợp lệ cho công cụ này.",
|
||||
"EEXISTS": "Đã có tài khoản trùng tên trong pool.",
|
||||
"ENOACCOUNT": "Tài khoản đó không còn trong pool.",
|
||||
"ELOCALMODE": "Ở chế độ local, pool được thay đổi từ shell của máy (agenthub accounts).",
|
||||
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
||||
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
|
||||
},
|
||||
"source": "Nguồn",
|
||||
"sources": {
|
||||
"profile": "Login đã lưu (profile Switch account)",
|
||||
"credentials": "Dán .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
||||
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Lưu ở Settings → Claude config → Switch account. Profile mà Claude Code của server đang dùng cũng chuyển được: server vẫn chạy trên nó và tự làm mới cho mọi người.",
|
||||
"chooseProfile": "Chọn login đã lưu…",
|
||||
"noProfiles": "Chưa có login nào — lưu ở Settings → Claude config → Switch account",
|
||||
"loadingProfiles": "Đang tải…",
|
||||
"activeHere": "server đang dùng — vẫn chạy tiếp",
|
||||
"nameFromProfile": "Để trống để dùng tên profile",
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key hoặc auth.json"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "Toàn bộ file: {\"claudeAiOauth\": {…}}",
|
||||
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
+48
-6
@@ -530,12 +530,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* A few pulses when the dot appears, then it rests lit. Status dots sit in
|
||||
always-visible chrome; an infinite animation there makes the browser draw a
|
||||
frame 60 times a second for as long as the page is open, which made the
|
||||
whole desktop stutter. A state change re-adds the class and pulses again. */
|
||||
.animate-pulse-dot {
|
||||
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) 3;
|
||||
}
|
||||
|
||||
/* The chat's live step, like Claude Code's: a light sweeping over the words.
|
||||
Reduced motion stops it on the muted colour. */
|
||||
/* The chat's live step, like Claude Code's: a light sweeping over the words —
|
||||
a few sweeps, then it rests on the muted colour (a background-position
|
||||
animation repaints the text every frame; the spinner beside it keeps saying
|
||||
the step runs). Reduced motion stops it at once. */
|
||||
.text-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
@@ -547,7 +553,7 @@
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: text-shimmer 2.5s linear infinite;
|
||||
animation: text-shimmer 2.5s linear 3;
|
||||
}
|
||||
|
||||
@keyframes text-shimmer {
|
||||
@@ -563,7 +569,7 @@
|
||||
reads it; no typography plugin. */
|
||||
.md-preview {
|
||||
color: rgb(var(--fg-secondary));
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -650,7 +656,7 @@
|
||||
.md-preview pre,
|
||||
.md-preview .md-frontmatter {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 6px;
|
||||
@@ -711,3 +717,39 @@
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
|
||||
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
|
||||
classes the app uses, re-stated in rem (identical at the default 16px) so
|
||||
the small labels grow with everything else. After `@tailwind utilities`,
|
||||
same specificity, so these win. A new `text-[Npx]` value belongs here too. */
|
||||
.text-\[9px\] {
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
.text-\[10px\] {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
.text-\[10\.5px\] {
|
||||
font-size: 0.65625rem;
|
||||
}
|
||||
.text-\[11px\] {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.text-\[12px\] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.text-\[12\.5px\] {
|
||||
font-size: 0.78125rem;
|
||||
}
|
||||
.text-\[13px\] {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.text-\[15px\] {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.text-\[16px\] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.text-\[18px\] {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file clipboard.test.ts
|
||||
* @description `writeClipboard` / `copyText`: the async Clipboard API when the
|
||||
* origin has one, else the hidden-textarea `execCommand("copy")` — the only
|
||||
* path on `http://<lan-ip>`, where a terminal selection must still reach the
|
||||
* user's clipboard — and the focus goes back to where it was.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { copyText, writeClipboard } from "../clipboard";
|
||||
|
||||
const original = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
||||
const setClipboard = (value: unknown) =>
|
||||
Object.defineProperty(navigator, "clipboard", { value, configurable: true });
|
||||
|
||||
afterEach(() => {
|
||||
if (original) Object.defineProperty(navigator, "clipboard", original);
|
||||
else delete (navigator as { clipboard?: unknown }).clipboard;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("writeClipboard", () => {
|
||||
it("uses the Clipboard API when the origin has one", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
setClipboard({ writeText });
|
||||
expect(await writeClipboard("hello")).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("falls back to execCommand on a non-secure origin, and gives the focus back", async () => {
|
||||
setClipboard(undefined);
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
let copied = "";
|
||||
const exec = vi.fn((cmd: string) => {
|
||||
copied = document.querySelector("textarea")?.value ?? "";
|
||||
return cmd === "copy";
|
||||
});
|
||||
Object.defineProperty(document, "execCommand", { value: exec, configurable: true });
|
||||
expect(await writeClipboard("from tmux")).toBe(true);
|
||||
expect(exec).toHaveBeenCalledWith("copy");
|
||||
expect(copied).toBe("from tmux");
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("copyText reports a failed copy", async () => {
|
||||
setClipboard(undefined);
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
value: vi.fn(() => false),
|
||||
configurable: true,
|
||||
});
|
||||
expect(await copyText("x", true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
readRunMenu,
|
||||
registerPane,
|
||||
takeRunInput,
|
||||
MENU_CURSOR_ROW,
|
||||
} from "../panePaste";
|
||||
|
||||
const RULE = "─".repeat(60);
|
||||
@@ -311,3 +312,17 @@ const CODEX_APPROVAL = [
|
||||
// "Press enter to confirm or esc to cancel", its dim cells left out.
|
||||
" enteresc",
|
||||
];
|
||||
|
||||
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]) {
|
||||
expect(parsePaneMenu(menu)).not.toBeNull();
|
||||
expect(has(menu)).toBe(true);
|
||||
}
|
||||
expect(has(CLAUDE)).toBe(false);
|
||||
expect(has(CODEX)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+534
-11
@@ -540,7 +540,88 @@ export type ServerUserUsage = AuthUser & {
|
||||
sessions: number;
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** Active login sessions (cookies not expired). */
|
||||
logins?: number;
|
||||
lastLoginAt?: 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 };
|
||||
workspaces: { id: number; title: string; cwd: string }[];
|
||||
};
|
||||
/** PATCH /api/users/:id — every field optional; only what changed is sent. */
|
||||
export type UserPatch = {
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
role?: AuthUser["role"];
|
||||
disabled?: boolean;
|
||||
password?: string;
|
||||
addWorkspaces?: number[];
|
||||
removeWorkspaces?: number[];
|
||||
signOut?: true;
|
||||
};
|
||||
/** GET /api/me/account — the signed-in account's own information. */
|
||||
export type MyAccount =
|
||||
| { mode: "local" }
|
||||
| {
|
||||
mode: "server";
|
||||
user: AuthUser & { disabled: boolean };
|
||||
workspaces: number;
|
||||
sessions: number;
|
||||
logins: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
/** One Claude pool account as `GET /api/accounts` reports it — never its token. */
|
||||
export type PoolAccount = {
|
||||
name: string;
|
||||
/** `token` = a setup-token; `login` = a real login placed in members' homes. */
|
||||
kind: "token" | "login";
|
||||
email: string | null;
|
||||
/** A login the server could not refresh (`ESTALE`: log in again). */
|
||||
refreshError: string | null;
|
||||
addedAt: string;
|
||||
expiresAt: string | null;
|
||||
expired: boolean;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
|
||||
export type CodexPoolAccount = {
|
||||
name: string;
|
||||
kind: "apikey" | "chatgpt";
|
||||
addedAt: string;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
export type AccountPool = {
|
||||
accounts: PoolAccount[];
|
||||
codex: CodexPoolAccount[];
|
||||
blockPercent: number;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex";
|
||||
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||
export type PoolProfile = {
|
||||
name: string;
|
||||
label: string;
|
||||
email: string | null;
|
||||
subscriptionType: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
|
||||
export type PoolAccountInput = {
|
||||
tool: PoolTool;
|
||||
name: string;
|
||||
profile?: string;
|
||||
credentials?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
/** A staff read of a member's session (`GET /api/users/audit`). */
|
||||
export type AuditEntry = {
|
||||
id: number;
|
||||
@@ -550,10 +631,21 @@ export type AuditEntry = {
|
||||
session_id: string | null;
|
||||
reader: string | null;
|
||||
owner: string | null;
|
||||
/** Their display names, when set — shown instead of the usernames. */
|
||||
reader_display?: string | null;
|
||||
owner_display?: string | null;
|
||||
};
|
||||
|
||||
/** Who is using the dashboard: local mode has no login at all. */
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: "admin" | "analyst" | "developer";
|
||||
/** Optional profile fields — absent from an older server's answers. */
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
|
||||
@@ -610,6 +702,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
auth: body?.error?.auth as GitAuthNeeded | undefined,
|
||||
// ESESSIONLIVE from a workspace start: the pane already holding it.
|
||||
runId: body?.error?.run_id as string | undefined,
|
||||
// EEXISTS from the File Explorer: the names already taken.
|
||||
names: body?.error?.names as string[] | undefined,
|
||||
});
|
||||
}
|
||||
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
|
||||
@@ -661,6 +755,54 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
|
||||
export type FsPlace = GitTarget | { home: string };
|
||||
export const isHomePlace = (p: FsPlace): p is { home: string } =>
|
||||
typeof p === "object" && "home" in p;
|
||||
|
||||
/** `/lanes/:id/fs<sub>` (same target as {@link gitUrl}) or `/homes/:who/fs<sub>`. */
|
||||
function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
if (isHomePlace(place)) {
|
||||
const qs = new URLSearchParams(q).toString();
|
||||
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
||||
}
|
||||
|
||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||
export interface FsHome {
|
||||
id: string;
|
||||
label: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
|
||||
export interface FsEntry {
|
||||
name: string;
|
||||
/** Workspace-relative path. */
|
||||
path: string;
|
||||
/** `link` is a symlink; `linkdir` one to a folder inside the workspace. */
|
||||
type: "dir" | "file" | "link" | "linkdir" | "other";
|
||||
size: number | null;
|
||||
mtime: number;
|
||||
}
|
||||
export interface FsListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: FsEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
export interface FsTrashItem {
|
||||
id: string;
|
||||
name: string;
|
||||
laneId: number | null;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
deletedAt: string;
|
||||
}
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
|
||||
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
||||
export function gitTarget(
|
||||
laneId: number,
|
||||
@@ -702,6 +844,19 @@ export const api = {
|
||||
}),
|
||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||
/** GET /api/me/account — this account's own information (server mode). */
|
||||
account: () => request<MyAccount>("/me/account"),
|
||||
/** PATCH /api/me/account — this account's display name and email. */
|
||||
updateAccount: (body: { displayName?: string | null; email?: string | null }) =>
|
||||
request<MyAccount>("/me/account", { method: "PATCH", body: JSON.stringify(body) }),
|
||||
/** POST /api/me/password — this login stays, every other one ends. */
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<{ ok: true }>("/me/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
/** 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" }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -721,16 +876,25 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** PATCH /api/users/:id — role and/or disabled (never your own). */
|
||||
update: (
|
||||
id: number,
|
||||
body: { role?: AuthUser["role"]; disabled?: boolean },
|
||||
currentPassword: string
|
||||
) =>
|
||||
/** 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
|
||||
* sign-out in ONE change (never your own role or disabling yourself). */
|
||||
update: (id: number, body: UserPatch, currentPassword: string) =>
|
||||
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
|
||||
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`, {
|
||||
@@ -744,6 +908,26 @@ export const api = {
|
||||
body: JSON.stringify({ laneId, currentPassword }),
|
||||
}),
|
||||
},
|
||||
/** The Claude/Codex account pool every member's new panes run on
|
||||
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
|
||||
accountPool: {
|
||||
/** GET /api/accounts — admin/analyst; `force` re-reads every quota. */
|
||||
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
|
||||
/** POST /api/accounts — admin, with their OWN password again. */
|
||||
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
|
||||
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
|
||||
add: (body: PoolAccountInput, currentPassword: string) =>
|
||||
request<{ account: { name: string } }>("/accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
||||
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ tool, currentPassword }),
|
||||
}),
|
||||
},
|
||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||
/** Self-update status: whether this install is a git clone and, if so,
|
||||
* whether the tracked upstream/origin remote is ahead. Backs the "update
|
||||
@@ -863,6 +1047,8 @@ export const api = {
|
||||
lane?: number;
|
||||
/** `false` = not archived, `true` = archived only; omitted = both. */
|
||||
archived?: boolean;
|
||||
/** Pinned sessions first (by pin time), before the limit. */
|
||||
pinnedFirst?: boolean;
|
||||
/** Only active sessions waiting on the user (`awaiting=1`). */
|
||||
awaiting?: boolean;
|
||||
/** ISO bounds on `started_at`. */
|
||||
@@ -881,6 +1067,7 @@ export const api = {
|
||||
if (params?.tool) qs.set("tool", params.tool);
|
||||
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
|
||||
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
||||
if (params?.awaiting) qs.set("awaiting", "1");
|
||||
if (params?.from) qs.set("from", params.from);
|
||||
if (params?.to) qs.set("to", params.to);
|
||||
@@ -934,9 +1121,15 @@ export const api = {
|
||||
* wherever it lies (local mode only), for an `<img>`. */
|
||||
readImageUrl: (id: string, path: string) =>
|
||||
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
|
||||
/** GET /api/sessions/:id/file — a text file the session itself read or
|
||||
* wrote, wherever it lies (an output outside the workspace). */
|
||||
readFile: (id: string, path: string) =>
|
||||
request<{ text: string; truncated: boolean }>(
|
||||
`/sessions/${encodeURIComponent(id)}/file?${new URLSearchParams({ path })}`
|
||||
),
|
||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||
* workspace, deletes nothing). */
|
||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||
patch: (id: string, body: { name?: string; archived?: boolean; pinned?: boolean }) =>
|
||||
request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
@@ -2310,6 +2503,143 @@ export const api = {
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
/**
|
||||
* The workspace File Explorer (`/api/lanes/:id/fs/*`, server/lib/file-explorer.js).
|
||||
* Every write runs as the folder's account; a taken name answers 409
|
||||
* EEXISTS with `names` unless `conflict` settles it.
|
||||
*/
|
||||
files: {
|
||||
/** GET /api/homes — the homes this user may open (their own; others' read-only for an admin). */
|
||||
homes: () => request<{ homes: FsHome[] }>("/homes"),
|
||||
/** One file's download URL, on a workspace or a home. */
|
||||
downloadUrl: (t: FsPlace, path: string) =>
|
||||
isHomePlace(t)
|
||||
? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`
|
||||
: `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`,
|
||||
list: (t: FsPlace, path = "") =>
|
||||
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
|
||||
mkdir: (t: FsPlace, path: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/mkdir"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
newFile: (t: FsPlace, path: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/newfile"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
rename: (t: FsPlace, path: string, name: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/rename"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path, name }),
|
||||
}),
|
||||
move: (t: FsPlace, paths: string[], dest: string, conflict?: FsConflict) =>
|
||||
request<{ moved: string[] }>(fsUrl(t, "/move"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths, dest, conflict }),
|
||||
}),
|
||||
/** Copy into `t`'s `dest` — from `t` itself, or from workspace `fromLane`. */
|
||||
copy: (
|
||||
t: FsPlace,
|
||||
paths: string[],
|
||||
dest: string,
|
||||
opts: { fromLane?: number; fromHome?: string; conflict?: FsConflict } = {}
|
||||
) =>
|
||||
request<{ copied: string[] }>(fsUrl(t, "/copy"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths, dest, ...opts }),
|
||||
}),
|
||||
/** Into the account's trash (restorable). */
|
||||
remove: (t: FsPlace, paths: string[]) =>
|
||||
request<{ trashed: string[] }>(fsUrl(t, "/delete"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
restore: (t: FsPlace, id: string) =>
|
||||
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
}),
|
||||
purge: (t: FsPlace, id: string | "all") =>
|
||||
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
}),
|
||||
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
|
||||
zipUrl: (t: FsPlace, paths: string[]) => {
|
||||
const q = new URLSearchParams();
|
||||
for (const p of paths) q.append("p", p);
|
||||
return `${BASE}${fsUrl(t, "/zip", q)}`;
|
||||
},
|
||||
/**
|
||||
* Stream `file` to `path` (sub-folders made as needed), reporting upload
|
||||
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
|
||||
*/
|
||||
upload: (
|
||||
t: FsPlace,
|
||||
path: string,
|
||||
file: Blob,
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) =>
|
||||
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
|
||||
path?: string;
|
||||
skipped?: boolean;
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as { path: string; skipped?: boolean });
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(file);
|
||||
}),
|
||||
},
|
||||
/** 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.
|
||||
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
|
||||
*/
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
|
||||
request<GitIdentity>("/me/git/ssh-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace, type }),
|
||||
}),
|
||||
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
request<GitIdentity>(`/me/git/https/${encodeURIComponent(host)}`, { method: "DELETE" }),
|
||||
/** PUT /api/me/git/author — `user.name` / `user.email` for the account's commits. */
|
||||
saveAuthor: (body: { name: string; email: string }) =>
|
||||
request<GitIdentity>("/me/git/author", { method: "PUT", body: JSON.stringify(body) }),
|
||||
},
|
||||
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
|
||||
snapshots: {
|
||||
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
|
||||
@@ -2328,6 +2658,58 @@ 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),
|
||||
}),
|
||||
update: (id: number, body: { title?: string; description?: string; shared?: boolean }) =>
|
||||
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"),
|
||||
@@ -2415,6 +2797,8 @@ export const api = {
|
||||
aiProxies: {
|
||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
||||
/** POST /api/ai-proxies - save a new profile. */
|
||||
create: (body: AiProxyInput) =>
|
||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||
@@ -2914,6 +3298,25 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/clone — `git clone <url>` into `<parent>/<name>` as the
|
||||
* folder's account, with that account's own SSH key / HTTPS login, then a
|
||||
* workspace on it. 409 `ECREDENTIALS` (the error's `auth` names the host)
|
||||
* asks for a login, sent again as `credentials`; 409 `ESSHAUTH` means the
|
||||
* SSH key was refused.
|
||||
*/
|
||||
clone: (body: {
|
||||
url: string;
|
||||
parent: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
tool?: LaneTool;
|
||||
credentials?: { username: string; password: string; remember?: boolean };
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||
@@ -2971,19 +3374,43 @@ export const api = {
|
||||
* filesystem paths from a native picker, so this drives a server-backed one.
|
||||
* @param path Absolute path to list; defaults server-side to the home dir.
|
||||
* @param files List files too (`file: true`) — a file field's picker.
|
||||
* @returns `{ path, parent, entries }` — `parent` is null at the root.
|
||||
* @param hidden List dot entries too (never at the top of a member's home).
|
||||
* @returns `{ path, parent, entries, home }` — `parent` is null at the
|
||||
* root, `home` is the folder the browser's Home place opens.
|
||||
*/
|
||||
browse: (path?: string, files?: boolean) => {
|
||||
browse: (path?: string, files?: boolean, hidden?: boolean, host?: boolean) => {
|
||||
const q = new URLSearchParams();
|
||||
if (path) q.set("path", path);
|
||||
if (files) q.set("files", "1");
|
||||
if (hidden) q.set("hidden", "1");
|
||||
if (host) q.set("host", "1");
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: { name: string; path: string; isGitRepo: boolean; file?: boolean }[];
|
||||
home?: string;
|
||||
/** An admin with their own Linux account (S3): the host account's
|
||||
* home, opened by `host=1` instead of theirs. */
|
||||
hostHome?: string;
|
||||
entries: {
|
||||
name: string;
|
||||
path: string;
|
||||
isGitRepo: boolean;
|
||||
file?: boolean;
|
||||
mtime?: number | null;
|
||||
}[];
|
||||
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
/**
|
||||
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
||||
* folder `name` inside `parent`. Throws `.code` `EBADNAME`, `EEXISTS`,
|
||||
* `EFOLDER` (outside the caller's own folder).
|
||||
*/
|
||||
mkdir: (parent: string, name: string) =>
|
||||
request<{ path: string }>("/lanes/browse/mkdir", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parent, name }),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
|
||||
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
|
||||
@@ -3065,6 +3492,24 @@ export const api = {
|
||||
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
|
||||
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
|
||||
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
|
||||
/** GET /api/lanes/:id/git/download — one working file as an attachment
|
||||
* (any type; a plain, non-git folder too). For an `<a download>`. */
|
||||
gitDownloadUrl: (id: GitTarget, path: string) =>
|
||||
`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`,
|
||||
/**
|
||||
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
|
||||
* ("" = the workspace root). Only creates: a taken name answers 409
|
||||
* EEXISTS; at most 25 MB (413 ETOOBIG).
|
||||
*/
|
||||
gitUpload: (id: GitTarget, dir: string, file: File) =>
|
||||
request<{ path: string }>(gitUrl(id, "/upload", new URLSearchParams({ dir })), {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-File-Name": encodeURIComponent(file.name),
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* PUT /api/lanes/:id/git/file — save the editor's text over the working
|
||||
* file. `baseSha` is the `sha` it was opened at; a file that changed on
|
||||
@@ -3259,6 +3704,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`, {
|
||||
@@ -3439,6 +3899,51 @@ 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;
|
||||
}
|
||||
|
||||
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`.
|
||||
@@ -3563,6 +4068,24 @@ export interface GitCredential {
|
||||
hasToken: true;
|
||||
}
|
||||
|
||||
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
||||
export interface GitIdentity {
|
||||
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
||||
account: "member" | "host";
|
||||
/** False for the host's identity seen by a non-admin. */
|
||||
writable: boolean;
|
||||
ssh: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string } | null;
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
https: {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string;
|
||||
savedAt?: string;
|
||||
hasToken: true;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface GitCredentialInput {
|
||||
protocol: string;
|
||||
host: string;
|
||||
|
||||
+20
-11
@@ -4,7 +4,9 @@
|
||||
* Uses the async Clipboard API when it exists and falls back to a hidden
|
||||
* textarea + `execCommand("copy")`, because `navigator.clipboard` is undefined
|
||||
* on a non-secure origin such as `http://<lan-ip>:4820`. Reports the outcome as
|
||||
* a toast, so a copy is never silent.
|
||||
* a toast, so a copy is never silent. `writeClipboard(text)` is the same two
|
||||
* paths with no toast, for a copy the user did not click (a terminal
|
||||
* selection); it hands the focus back to wherever it was.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +14,8 @@ import i18n from "../i18n";
|
||||
import { toast } from "./toast";
|
||||
|
||||
function fallbackCopy(text: string): boolean {
|
||||
// Selecting the textarea moves the focus: give it back (a terminal keeps typing).
|
||||
const was = document.activeElement as HTMLElement | null;
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
@@ -25,25 +29,30 @@ function fallbackCopy(text: string): boolean {
|
||||
return false;
|
||||
} finally {
|
||||
ta.remove();
|
||||
was?.focus?.({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy `text` silently: the Clipboard API, else the textarea path. */
|
||||
export async function writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the textarea path */
|
||||
}
|
||||
return fallbackCopy(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard and toast the result.
|
||||
* @param quiet Skip the success toast (the caller shows its own feedback).
|
||||
* @returns Whether the copy succeeded.
|
||||
*/
|
||||
export async function copyText(text: string, quiet = false): Promise<boolean> {
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the textarea path */
|
||||
}
|
||||
if (!ok) ok = fallbackCopy(text);
|
||||
const ok = await writeClipboard(text);
|
||||
if (!ok) toast.error(i18n.t("common:copyFailed"));
|
||||
else if (!quiet) toast.success(i18n.t("common:copied"));
|
||||
return ok;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file fontSize — the two text sizes Settings → Appearance offers (issue #79),
|
||||
* viewer preferences kept in `localStorage` and synced to the account like the
|
||||
* theme (`lib/userPrefs.ts` PREF_KEYS):
|
||||
* - `agenthub.ui.fontSize`: the interface's size, the ROOT font size in px.
|
||||
* Tailwind's sizes and spacing are rem, and `index.css` re-states every
|
||||
* `text-[Npx]` class the app uses in rem, so the whole UI follows it.
|
||||
* `index.html` applies it before first paint (same key, same range).
|
||||
* Capped at 18px: the diff view pins its rows at `ROW_PX` (16px).
|
||||
* - `agenthub.terminal.fontSize`: xterm's cell font size in px (default 13).
|
||||
* A write fires `FONT_SIZE_EVENT`, which open terminals listen for to re-fit.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const UI_FONT_SIZE_KEY = "agenthub.ui.fontSize";
|
||||
export const TERMINAL_FONT_SIZE_KEY = "agenthub.terminal.fontSize";
|
||||
/** Fired on `window` after either size changes (this tab or a hydrate). */
|
||||
export const FONT_SIZE_EVENT = "agenthub-font-size";
|
||||
|
||||
/** The interface sizes, root px; 16 is the browser's own default. */
|
||||
export const UI_FONT_SIZES = [14, 16, 17, 18] as const;
|
||||
export type UiFontSize = (typeof UI_FONT_SIZES)[number];
|
||||
export const DEFAULT_UI_FONT_SIZE: UiFontSize = 16;
|
||||
|
||||
export const TERMINAL_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18, 20] as const;
|
||||
export const DEFAULT_TERMINAL_FONT_SIZE = 13;
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: number) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
/* storage blocked — this page still applies it */
|
||||
}
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
}
|
||||
|
||||
export function readUiFontSize(): UiFontSize {
|
||||
const n = Number(read(UI_FONT_SIZE_KEY));
|
||||
return (UI_FONT_SIZES as readonly number[]).includes(n)
|
||||
? (n as UiFontSize)
|
||||
: DEFAULT_UI_FONT_SIZE;
|
||||
}
|
||||
|
||||
/** Paint the stored interface size on `<html>` (the default leaves it unset). */
|
||||
export function applyUiFontSize(size: UiFontSize = readUiFontSize()) {
|
||||
const root = document.documentElement;
|
||||
if (size === DEFAULT_UI_FONT_SIZE) root.style.removeProperty("font-size");
|
||||
else root.style.fontSize = `${size}px`;
|
||||
}
|
||||
|
||||
export function writeUiFontSize(size: UiFontSize) {
|
||||
applyUiFontSize(size);
|
||||
write(UI_FONT_SIZE_KEY, size);
|
||||
}
|
||||
|
||||
export function readTerminalFontSize(): number {
|
||||
const n = Number(read(TERMINAL_FONT_SIZE_KEY));
|
||||
return (TERMINAL_FONT_SIZES as readonly number[]).includes(n) ? n : DEFAULT_TERMINAL_FONT_SIZE;
|
||||
}
|
||||
|
||||
export function writeTerminalFontSize(size: number) {
|
||||
write(TERMINAL_FONT_SIZE_KEY, size);
|
||||
}
|
||||
@@ -27,7 +27,8 @@ interface Pane {
|
||||
paste: (text: string, submit?: boolean) => void;
|
||||
visible: () => boolean;
|
||||
key?: (data: string) => void;
|
||||
screen?: () => PaneScreen | null;
|
||||
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null;
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
@@ -39,7 +40,7 @@ export function registerPane(
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true,
|
||||
key?: (data: string) => void,
|
||||
screen?: () => PaneScreen | null
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null
|
||||
): () => void {
|
||||
const entry = { paste, visible, key, screen };
|
||||
panes.set(runId, entry);
|
||||
@@ -175,6 +176,8 @@ 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*$)/;
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
|
||||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||||
@@ -251,7 +254,9 @@ function typeRow(options: PaneMenuOption[]): number {
|
||||
/** The menu `runId`'s visible pane shows now, or null. */
|
||||
export function readRunMenu(runId: string): PaneMenu | null {
|
||||
const p = panes.get(runId);
|
||||
const screen = p?.visible() && p.key ? p.screen?.() : null;
|
||||
// Polled every 700 ms while a chat shows: a screen with no cursor row is
|
||||
// answered from plain text, without reading it cell by cell.
|
||||
const screen = p?.visible() && p.key ? p.screen?.(true) : null;
|
||||
return screen ? parsePaneMenu(screen.rows) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.-]+)?$/;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user