Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e94038481e | |||
| b441f32949 | |||
| cb19d7e5d2 | |||
| 0cb5e41faf | |||
| 1c5521cf2e | |||
| c3f83d3922 | |||
| 0c2f49876d | |||
| 9c573f1e3c | |||
| d72a255294 | |||
| b08482c6aa | |||
| 763759189f | |||
| b0dd5af22f | |||
| 7cb4f64781 | |||
| 69a2c37cd2 | |||
| b3fe10a4f3 | |||
| 8ecb96e9fc | |||
| c47dd230d4 | |||
| 46e3899945 | |||
| 5d8fc9bb98 | |||
| c8ea18f594 | |||
| 64173a5428 | |||
| 943ba03b76 | |||
| 3a8be55b55 |
@@ -22,7 +22,8 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
||||||
"timeout": 5
|
"timeout": 5,
|
||||||
|
"async": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
||||||
"timeout": 5
|
"timeout": 5,
|
||||||
|
"async": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ make while you work here as Codex.
|
|||||||
- `scripts/` for hook/install/import/cleanup utilities.
|
- `scripts/` for hook/install/import/cleanup utilities.
|
||||||
|
|
||||||
## Validation expectations
|
## Validation expectations
|
||||||
- Backend changes: run `npm run test:server` when possible.
|
- Run only the tests the change touches, never a full suite by default (full suites take 5–6 minutes): `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Docs-only changes run no tests.
|
||||||
- Frontend changes: run `npm run test:client` when possible.
|
- 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.
|
- If any check is skipped, report it explicitly.
|
||||||
|
|
||||||
## Safety expectations
|
## Safety expectations
|
||||||
|
|||||||
+17
-14
@@ -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-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`. |
|
| `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/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/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-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 |
|
| `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-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-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/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/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/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 |
|
| `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 metadata "JSON blob"
|
||||||
TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
|
TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
|
||||||
TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
|
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'"
|
TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2323,7 +2325,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
|||||||
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
|
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
|
||||||
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
|
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
|
||||||
|
|
||||||
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
|
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2354,7 +2356,7 @@ graph TD
|
|||||||
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
||||||
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||||
PANEL["SagiPanel.tsx<br/>status + quick actions + Ask box"]
|
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
|
||||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2401,16 +2403,17 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
|
|||||||
|
|
||||||
| Component | Responsibility |
|
| 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. |
|
| **`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 queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
|
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
|
||||||
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
|
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
|
||||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
|
| **`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: status readout + quick actions + the Ask box. |
|
| **`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. |
|
||||||
| **`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. |
|
| **`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. |
|
||||||
| **`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. |
|
| **`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. |
|
||||||
| **`quips.ts`** | Pure mood → phrase pools. |
|
| **`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. |
|
||||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
|
||||||
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
|
| **`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
|
### 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/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. |
|
| `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. |
|
| `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
|
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
- **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.
|
**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">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<!-- Apply the saved appearance before first paint (same keys as
|
<!-- 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>
|
<script>
|
||||||
try {
|
try {
|
||||||
var m = localStorage.getItem("theme");
|
var m = localStorage.getItem("theme");
|
||||||
@@ -17,6 +18,9 @@
|
|||||||
r.classList.toggle("dark", d);
|
r.classList.toggle("dark", d);
|
||||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
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) {}
|
} catch (e) {}
|
||||||
</script>
|
</script>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|||||||
Generated
-42
@@ -1204,9 +1204,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1362,9 +1359,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1379,9 +1373,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1396,9 +1387,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1413,9 +1401,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1430,9 +1415,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1447,9 +1429,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1464,9 +1443,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1481,9 +1457,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1498,9 +1471,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1515,9 +1485,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1532,9 +1499,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1549,9 +1513,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1566,9 +1527,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const SessionDetail = lazy(() =>
|
|||||||
const SnapshotView = lazy(() =>
|
const SnapshotView = lazy(() =>
|
||||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||||
);
|
);
|
||||||
|
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||||
|
|
||||||
function PageFallback() {
|
function PageFallback() {
|
||||||
@@ -210,6 +211,14 @@ export default function App() {
|
|||||||
</Page>
|
</Page>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="files"
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<FilesPage />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="users"
|
path="users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
* 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`
|
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
* (`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.
|
* `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
|
* Once an account is known (local mode is one), its own UI preferences are
|
||||||
* loaded into this browser (`lib/userPrefs.ts`).
|
* loaded into this browser (`lib/userPrefs.ts`).
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
@@ -30,9 +32,17 @@ import { applySystemTheme } from "../hooks/useTheme";
|
|||||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||||
import { hydrate } from "../lib/userPrefs";
|
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). */
|
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||||
export const useAuth = () => useContext(AuthContext);
|
export const useAuth = () => useContext(AuthContext);
|
||||||
@@ -134,6 +144,15 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
if (account) void hydrate();
|
if (account) void hydrate();
|
||||||
}, [account]);
|
}, [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) return null;
|
||||||
if (state.mode === "server" && !state.user) {
|
if (state.mode === "server" && !state.user) {
|
||||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||||
@@ -142,7 +161,9 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
await api.auth.logout().catch(() => {});
|
await api.auth.logout().catch(() => {});
|
||||||
setState({ mode: "server", user: null });
|
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 }) {
|
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||||
|
|||||||
@@ -6,36 +6,29 @@
|
|||||||
* user chose. A browser's picker cannot give a page that path, so the dialog
|
* user chose. A browser's picker cannot give a page that path, so the dialog
|
||||||
* opens on the server's desktop — which is only this user's screen when the
|
* opens on the server's desktop — which is only this user's screen when the
|
||||||
* browser runs on the same machine. Anywhere else (a LAN browser, a server
|
* browser runs on the same machine. Anywhere else (a LAN browser, a server
|
||||||
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
|
* with no desktop or no zenity/kdialog) it falls back to the in-app
|
||||||
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
|
* `FolderBrowseModal` (as it does on any other failure) — for a file field
|
||||||
* the path.
|
* too, which then lists files and picks one on click.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { toast } from "../lib/toast";
|
|
||||||
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
|
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
|
||||||
|
|
||||||
/** Server answers that mean "no native picker for this browser" — not a fault. */
|
|
||||||
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
|
|
||||||
|
|
||||||
export function PathPickButton({
|
export function PathPickButton({
|
||||||
kind = "dir",
|
kind = "dir",
|
||||||
start,
|
start,
|
||||||
onPick,
|
onPick,
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
buttonRef,
|
|
||||||
}: {
|
}: {
|
||||||
kind?: "dir" | "file";
|
kind?: "dir" | "file";
|
||||||
/** The field's current value — the picker opens there. */
|
/** The field's current value — the picker opens there. */
|
||||||
start?: string;
|
start?: string;
|
||||||
onPick: (path: string) => void;
|
onPick: (path: string) => void;
|
||||||
showLabel?: boolean;
|
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 { t } = useTranslation(["lanes"]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -47,15 +40,10 @@ export function PathPickButton({
|
|||||||
try {
|
try {
|
||||||
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
|
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
|
||||||
if (r.path) onPick(r.path);
|
if (r.path) onPick(r.path);
|
||||||
} catch (err) {
|
} catch {
|
||||||
const code = (err as { code?: string }).code;
|
// The in-app browser is the fallback whatever went wrong with the
|
||||||
// A folder always has the in-app browser to fall back on, whatever went
|
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
|
||||||
// wrong with the native one (a crashed dialog, one already open).
|
setFallback(true);
|
||||||
if (kind === "dir") setFallback(true);
|
|
||||||
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
|
|
||||||
else {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -64,7 +52,6 @@ export function PathPickButton({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -80,6 +67,7 @@ export function PathPickButton({
|
|||||||
<div className="contents" onClick={(e) => e.stopPropagation()}>
|
<div className="contents" onClick={(e) => e.stopPropagation()}>
|
||||||
<FolderBrowseModal
|
<FolderBrowseModal
|
||||||
open={fallback}
|
open={fallback}
|
||||||
|
kind={kind}
|
||||||
initialPath={start?.trim() || undefined}
|
initialPath={start?.trim() || undefined}
|
||||||
onSelect={onPick}
|
onSelect={onPick}
|
||||||
onClose={() => setFallback(false)}
|
onClose={() => setFallback(false)}
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
/**
|
||||||
|
* @file PetChat.tsx
|
||||||
|
* @description The pet's chat window. A question its Ask box cannot answer
|
||||||
|
* locally opens here instead of leaving the page: the first message starts a
|
||||||
|
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||||
|
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||||
|
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||||
|
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||||
|
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||||
|
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||||
|
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||||
|
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||||
|
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||||
|
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
||||||
|
* forgets it (the session stays in the workspace), **Open in workspace**
|
||||||
|
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||||
|
* is gone, the next message starts a new one.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { TranscriptMessage } from "../../lib/types";
|
||||||
|
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||||
|
import {
|
||||||
|
answerRunMenu,
|
||||||
|
chooseRunOption,
|
||||||
|
interruptRun,
|
||||||
|
readRunMenu,
|
||||||
|
submitToRun,
|
||||||
|
type PaneMenu,
|
||||||
|
} from "../../lib/panePaste";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||||
|
import { TerminalView } from "../run/TerminalView";
|
||||||
|
import { PaneMenuCard } from "../run/PaneChat";
|
||||||
|
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||||
|
|
||||||
|
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||||
|
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||||
|
const CHAT_KEY = "agenthub.pet.chat";
|
||||||
|
const POLL_MS = 1500;
|
||||||
|
const MENU_POLL_MS = 700;
|
||||||
|
// A follow-up waits this long for the off-screen pane to attach.
|
||||||
|
const SEND_TRIES = 20;
|
||||||
|
const SEND_RETRY_MS = 250;
|
||||||
|
|
||||||
|
/** The session the chat is talking to. */
|
||||||
|
export interface PetChatRef {
|
||||||
|
laneId: number;
|
||||||
|
runId: string;
|
||||||
|
tool: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPetChat(): PetChatRef | null {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||||
|
if (
|
||||||
|
v &&
|
||||||
|
typeof v.laneId === "number" &&
|
||||||
|
typeof v.runId === "string" &&
|
||||||
|
typeof v.tool === "string"
|
||||||
|
)
|
||||||
|
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||||
|
} catch {
|
||||||
|
/* unreadable: no chat */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePetChat(ref: PetChatRef | null) {
|
||||||
|
try {
|
||||||
|
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||||
|
else localStorage.removeItem(CHAT_KEY);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||||
|
export function petChatTool(): string | null {
|
||||||
|
const tool = readDefaultTool() ?? "claude";
|
||||||
|
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Line =
|
||||||
|
| { key: string; role: "user" | "assistant"; text: string }
|
||||||
|
| { key: string; role: "tools"; names: string[] };
|
||||||
|
|
||||||
|
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||||
|
* `<system-reminder>`…) are not something the user said. */
|
||||||
|
const humanText = (m: TranscriptMessage) =>
|
||||||
|
m.content
|
||||||
|
.filter((c) => c.type === "text" && c.text)
|
||||||
|
.map((c) => c.text!.trim())
|
||||||
|
.filter((t) => t && !t.startsWith("<"))
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||||
|
* each run of tool calls between them folded into one chip. */
|
||||||
|
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||||
|
const out: Line[] = [];
|
||||||
|
messages.forEach((m, i) => {
|
||||||
|
const sender = m.sender ?? m.type;
|
||||||
|
if (sender === "user") {
|
||||||
|
const text = humanText(m);
|
||||||
|
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.type !== "assistant") return;
|
||||||
|
m.content.forEach((c, j) => {
|
||||||
|
if (c.type === "text" && c.text?.trim()) {
|
||||||
|
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||||
|
} else if (c.type === "tool_use" && c.name) {
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last?.role === "tools") last.names.push(c.name);
|
||||||
|
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||||
|
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i]!;
|
||||||
|
const sender = m.sender ?? m.type;
|
||||||
|
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||||
|
if (sender === "user" && humanText(m)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Read ×2 · Bash" */
|
||||||
|
function toolSummary(names: string[]): string {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||||
|
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PetChatProps {
|
||||||
|
/** The pet's shown name, for the header and greeting. */
|
||||||
|
name: string;
|
||||||
|
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
||||||
|
initialPrompt?: string | null;
|
||||||
|
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
||||||
|
onPromptTaken?: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Opens the chat's pane in its workspace. */
|
||||||
|
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||||
|
/** The thinking mood while a reply is being written. */
|
||||||
|
onBusy?: (busy: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PetChat({
|
||||||
|
name,
|
||||||
|
initialPrompt,
|
||||||
|
onPromptTaken,
|
||||||
|
onBack,
|
||||||
|
onClose,
|
||||||
|
onOpenWorkspace,
|
||||||
|
onBusy,
|
||||||
|
}: PetChatProps) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
|
// Sent but not in the transcript yet.
|
||||||
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const remember = (ref: PetChatRef | null) => {
|
||||||
|
writePetChat(ref);
|
||||||
|
setChat(ref);
|
||||||
|
setSessionId(null);
|
||||||
|
setMessages([]);
|
||||||
|
setMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chat) return;
|
||||||
|
let live = true;
|
||||||
|
const check = () =>
|
||||||
|
api.run
|
||||||
|
.get(chat.runId)
|
||||||
|
.then((h) => {
|
||||||
|
if (!live) return;
|
||||||
|
if (h.status !== "running") remember(null);
|
||||||
|
else if (h.sessionId) setSessionId(h.sessionId);
|
||||||
|
})
|
||||||
|
.catch(() => live && remember(null));
|
||||||
|
void check();
|
||||||
|
// Codex learns its session only after a while; Claude's is known at start.
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (!sessionId) void check();
|
||||||
|
}, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chat?.runId, sessionId]);
|
||||||
|
|
||||||
|
// The conversation, polled while the window is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
let live = true;
|
||||||
|
const load = () =>
|
||||||
|
api.sessions
|
||||||
|
.transcript(sessionId, { limit: 60 })
|
||||||
|
.then((r) => {
|
||||||
|
if (!live) return;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.length === r.messages.length &&
|
||||||
|
JSON.stringify(prev[prev.length - 1]) ===
|
||||||
|
JSON.stringify(r.messages[r.messages.length - 1])
|
||||||
|
? prev
|
||||||
|
: r.messages
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => undefined); // not started yet: the next poll asks again
|
||||||
|
void load();
|
||||||
|
const timer = window.setInterval(load, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chat) return;
|
||||||
|
const read = () =>
|
||||||
|
setMenu((prev) => {
|
||||||
|
const next = readRunMenu(chat.runId);
|
||||||
|
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||||
|
});
|
||||||
|
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [chat]);
|
||||||
|
|
||||||
|
const lines = chatLines(messages);
|
||||||
|
// The pending line shows until the transcript has it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||||
|
setPending(null);
|
||||||
|
}, [lines, pending]);
|
||||||
|
const busy = starting || !!pending || isBusy(messages);
|
||||||
|
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||||
|
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||||
|
|
||||||
|
// Keep the newest in sight.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [lines.length, pending, busy, menu]);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
const prompt = text.trim();
|
||||||
|
if (!prompt || starting) return;
|
||||||
|
setError(null);
|
||||||
|
setPending(prompt);
|
||||||
|
if (chat) {
|
||||||
|
// Typed into the attached pane; it may still be attaching.
|
||||||
|
for (let i = 0; i < SEND_TRIES; i++) {
|
||||||
|
if (submitToRun(chat.runId, prompt)) return;
|
||||||
|
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||||
|
}
|
||||||
|
setPending(null);
|
||||||
|
setDraft(prompt);
|
||||||
|
setError(t("sagi.chat.notSent"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tool = petChatTool() ?? "claude";
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||||
|
const r = await startInFreeTabDetailed(lane.id, {
|
||||||
|
prompt,
|
||||||
|
...(tool !== lane.tool ? { tool } : {}),
|
||||||
|
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||||
|
});
|
||||||
|
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||||
|
} catch (err) {
|
||||||
|
setPending(null);
|
||||||
|
setDraft(prompt);
|
||||||
|
setError(
|
||||||
|
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[chat, starting, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Ask box's question, sent once.
|
||||||
|
const taken = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialPrompt || taken.current) return;
|
||||||
|
taken.current = true;
|
||||||
|
onPromptTaken?.();
|
||||||
|
void send(initialPrompt);
|
||||||
|
}, [initialPrompt, onPromptTaken, send]);
|
||||||
|
|
||||||
|
const submit = (e?: FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!draft.trim()) return;
|
||||||
|
const text = draft;
|
||||||
|
setDraft("");
|
||||||
|
void send(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||||
|
const empty = lines.length === 0 && !pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t("sagi.chat.label", { name })}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||||
|
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<div className="min-w-0 flex-1 px-1">
|
||||||
|
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
|
busy
|
||||||
|
? "animate-pulse bg-status-warning"
|
||||||
|
: chat
|
||||||
|
? "bg-status-success"
|
||||||
|
: "bg-fg-muted"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{chat && (
|
||||||
|
<IconButton
|
||||||
|
label={t("sagi.chat.openWorkspace")}
|
||||||
|
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||||
|
>
|
||||||
|
<ExternalLink size={15} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
label={t("sagi.chat.new")}
|
||||||
|
onClick={() => remember(null)}
|
||||||
|
disabled={!chat || starting}
|
||||||
|
>
|
||||||
|
<SquarePen size={15} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* conversation */}
|
||||||
|
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||||
|
{empty && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||||
|
<span className="text-3xl" aria-hidden>
|
||||||
|
🐾
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium text-fg-primary">
|
||||||
|
{t("sagi.chat.emptyTitle", { name })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lines.map((l) =>
|
||||||
|
l.role === "tools" ? (
|
||||||
|
<div key={l.key} className="flex justify-start">
|
||||||
|
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||||
|
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||||
|
<span className="truncate">{toolSummary(l.names)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : l.role === "user" ? (
|
||||||
|
<div key={l.key} className="flex justify-end">
|
||||||
|
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||||
|
{l.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={l.key} className="flex justify-start">
|
||||||
|
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||||
|
<MarkdownContent text={l.text} dense prose />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{pending && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||||
|
{pending}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{busy && !menu && (
|
||||||
|
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||||
|
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||||
|
<span className="pet-chat-dot" />
|
||||||
|
<span className="pet-chat-dot" />
|
||||||
|
<span className="pet-chat-dot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{starting && (
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||||
|
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||||
|
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chat && !sessionId && !starting && (
|
||||||
|
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* prompt, or the question the CLI asks */}
|
||||||
|
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||||
|
{menu && chat ? (
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
<PaneMenuCard
|
||||||
|
menu={menu}
|
||||||
|
onChoose={(i) => {
|
||||||
|
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||||
|
else setError(t("sagi.chat.notSent"));
|
||||||
|
}}
|
||||||
|
onAnswer={(text) => {
|
||||||
|
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||||
|
else setError(t("sagi.chat.notSent"));
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
if (interruptRun(chat.runId)) setMenu(null);
|
||||||
|
else setError(t("sagi.chat.notSent"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||||
|
<textarea
|
||||||
|
rows={1}
|
||||||
|
autoFocus
|
||||||
|
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||||
|
placeholder={t("sagi.chat.placeholder", { name })}
|
||||||
|
aria-label={t("sagi.chat.placeholder", { name })}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
// Esc leaves the chat for the panel, not the whole mascot.
|
||||||
|
e.stopPropagation();
|
||||||
|
e.nativeEvent.stopImmediatePropagation();
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!draft.trim() || starting}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
|
aria-label={t("sagi.panel.send")}
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||||
|
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||||
|
{chat && (
|
||||||
|
<div className="pet-chat-term" aria-hidden>
|
||||||
|
<TerminalView
|
||||||
|
runId={chat.runId}
|
||||||
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
|
autoFocus={false}
|
||||||
|
// The pane ended: the conversation stays on screen, the next
|
||||||
|
// message starts a new session.
|
||||||
|
onExit={() => {
|
||||||
|
writePetChat(null);
|
||||||
|
setChat(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
* 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
|
* 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
|
* 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
|
* 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
|
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { Mood } from "./brain";
|
import type { Mood, PetMoment } from "./brain";
|
||||||
|
|
||||||
const CELL = { w: 192, h: 208 };
|
const CELL = { w: 192, h: 208 };
|
||||||
const COLS = 8;
|
const COLS = 8;
|
||||||
@@ -58,12 +60,18 @@ const ROW: Record<Mood, number> = {
|
|||||||
/** What the user is doing to the pet, which outranks its mood. */
|
/** What the user is doing to the pet, which outranks its mood. */
|
||||||
export type PetAction = "run-left" | "run-right" | "wave";
|
export type PetAction = "run-left" | "run-right" | "wave";
|
||||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
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({
|
export function PetSprite({
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
mood,
|
mood,
|
||||||
action,
|
action,
|
||||||
|
moment,
|
||||||
gaze,
|
gaze,
|
||||||
size,
|
size,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
@@ -73,6 +81,7 @@ export function PetSprite({
|
|||||||
kind: "codex" | "image";
|
kind: "codex" | "image";
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
action?: PetAction | null;
|
action?: PetAction | null;
|
||||||
|
moment?: PetMoment | null;
|
||||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||||
gaze?: number | null;
|
gaze?: number | null;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -80,8 +89,8 @@ export function PetSprite({
|
|||||||
onBroken: () => void;
|
onBroken: () => void;
|
||||||
}) {
|
}) {
|
||||||
const url = api.pets.spriteUrl(id);
|
const url = api.pets.spriteUrl(id);
|
||||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0);
|
||||||
const sleeping = !action && mood === "sleeping";
|
const sleeping = !action && !moment && mood === "sleeping";
|
||||||
// The cell on show, tagged with the row it was played for: a cell left over
|
// 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.
|
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||||
@@ -95,13 +104,17 @@ export function PetSprite({
|
|||||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
let loops = 0;
|
||||||
let timer = 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 = () => {
|
const tick = () => {
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
if (++i === list.length) {
|
if (++i === list.length) {
|
||||||
i = 0;
|
i = 0;
|
||||||
if (MOMENTS.has(row)) list = play(0);
|
if (MOMENTS.has(row)) list = play(0);
|
||||||
|
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
|
||||||
}
|
}
|
||||||
setCell(list[i]!);
|
setCell(list[i]!);
|
||||||
tick();
|
tick();
|
||||||
@@ -132,6 +145,7 @@ export function PetSprite({
|
|||||||
src={url}
|
src={url}
|
||||||
alt=""
|
alt=""
|
||||||
data-mood={mood}
|
data-mood={mood}
|
||||||
|
data-moment={moment ?? undefined}
|
||||||
className="sagi-pet-image"
|
className="sagi-pet-image"
|
||||||
width={size}
|
width={size}
|
||||||
height={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 =
|
const shown =
|
||||||
pose != null
|
pose != null
|
||||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SagiAvatar } from "./SagiAvatar";
|
import { SagiAvatar } from "./SagiAvatar";
|
||||||
import { PetSprite } from "./PetSprite";
|
import { PetSprite } from "./PetSprite";
|
||||||
@@ -87,8 +88,19 @@ import { useSagiBrain } from "./useSagiBrain";
|
|||||||
import { useSagiPosition } from "./useSagiPosition";
|
import { useSagiPosition } from "./useSagiPosition";
|
||||||
import { useSagiEnabled } from "./SagiMount";
|
import { useSagiEnabled } from "./SagiMount";
|
||||||
import { matchIntent } from "./intents";
|
import { matchIntent } from "./intents";
|
||||||
import { sagiPrefs } from "./prefs";
|
import { petName, sagiPrefs } from "./prefs";
|
||||||
|
import type { SagiSubject } from "./brain";
|
||||||
import { api } from "../../lib/api";
|
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";
|
import "./sagi.css";
|
||||||
|
|
||||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||||
@@ -208,13 +220,19 @@ export function Sagi() {
|
|||||||
function SagiBody() {
|
function SagiBody() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
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 [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
// The chat window (PetChat.tsx), and the Ask question it should send.
|
||||||
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
|
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
|
||||||
// The right-click pet menu (`PetMenu.tsx`).
|
// The right-click pet menu (`PetMenu.tsx`).
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const reducedMotion = usePrefersReducedMotion();
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const href = useSettingsHref();
|
||||||
const brain = useSagiBrain();
|
const brain = useSagiBrain();
|
||||||
const place = useSagiPosition(size);
|
const place = useSagiPosition(size);
|
||||||
|
|
||||||
@@ -224,11 +242,32 @@ function SagiBody() {
|
|||||||
() =>
|
() =>
|
||||||
sagiPrefs.subscribe(() => {
|
sagiPrefs.subscribe(() => {
|
||||||
setPet(sagiPrefs.getPet());
|
setPet(sagiPrefs.getPet());
|
||||||
|
setName(petName(sagiPrefs.getPet()));
|
||||||
setSize(sagiPrefs.getSize());
|
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
|
// ⌘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.
|
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -239,6 +278,7 @@ function SagiBody() {
|
|||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setChatOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
@@ -279,27 +319,53 @@ function SagiBody() {
|
|||||||
[navigate]
|
[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(
|
const onAsk = useCallback(
|
||||||
(query: string): string | null => {
|
(query: string): string | null => {
|
||||||
const result = matchIntent(query, brain.status);
|
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||||
if (result.kind === "answer") return result.text;
|
if (result.kind === "answer") {
|
||||||
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
if (result.moment) brain.playMoment(result.moment);
|
||||||
// fresh claude session in it carrying the user's prompt as its first
|
return result.text;
|
||||||
// message, then open that workspace's session view on the new pane.
|
}
|
||||||
// Fire-and-forget — the panel already clears its composer on null and
|
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||||
// closes; errors surface as an answer instead of silently swallowing.
|
// 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;
|
const prompt = result.prompt;
|
||||||
brain.setThinking(true);
|
brain.setThinking(true);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||||
const run = await api.run.start({
|
const tool = readDefaultTool();
|
||||||
laneId: lane.id,
|
const r = await startInFreeTabDetailed(lane.id, {
|
||||||
cwd: lane.cwd,
|
prompt,
|
||||||
initialPrompt: prompt,
|
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
});
|
});
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -310,9 +376,32 @@ function SagiBody() {
|
|||||||
})();
|
})();
|
||||||
return null;
|
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 = {
|
const anchor: Anchor = {
|
||||||
left: place.left,
|
left: place.left,
|
||||||
top: place.top,
|
top: place.top,
|
||||||
@@ -329,17 +418,52 @@ function SagiBody() {
|
|||||||
{!place.dragging && open && (
|
{!place.dragging && open && (
|
||||||
<SagiFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<SagiPanel
|
<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}
|
status={brain.status}
|
||||||
muted={brain.muted}
|
muted={brain.muted}
|
||||||
onToggleMute={brain.toggleMute}
|
onToggleMute={brain.toggleMute}
|
||||||
onClearAlerts={brain.clearAlerts}
|
onClearAlerts={brain.clearAlerts}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onAsk={onAsk}
|
onAsk={onAsk}
|
||||||
|
onOpenChat={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setChatOpen(true);
|
||||||
|
}}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Kept mounted while dragging (only hidden): unmounting would detach
|
||||||
|
its terminal and lose a half-typed message. */}
|
||||||
|
{chatOpen && (
|
||||||
|
<div style={place.dragging ? { display: "none" } : undefined}>
|
||||||
|
<SagiFlyout anchor={anchor}>
|
||||||
|
<PetChat
|
||||||
|
name={name}
|
||||||
|
initialPrompt={chatPrompt}
|
||||||
|
onPromptTaken={() => setChatPrompt(null)}
|
||||||
|
onBack={() => {
|
||||||
|
setChatOpen(false);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onClose={() => setChatOpen(false)}
|
||||||
|
onOpenWorkspace={(laneId, runId) => {
|
||||||
|
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||||
|
setChatOpen(false);
|
||||||
|
}}
|
||||||
|
onBusy={onBusy}
|
||||||
|
/>
|
||||||
|
</SagiFlyout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!place.dragging && menu && (
|
{!place.dragging && menu && (
|
||||||
<SagiFlyout anchor={anchor} front>
|
<SagiFlyout anchor={anchor} front>
|
||||||
<PetMenu
|
<PetMenu
|
||||||
@@ -348,15 +472,19 @@ function SagiBody() {
|
|||||||
onClose={() => setMenu(false)}
|
onClose={() => setMenu(false)}
|
||||||
onSettings={() => {
|
onSettings={() => {
|
||||||
setMenu(false);
|
setMenu(false);
|
||||||
navigate("/settings/appearance#pet");
|
navigate(href("appearance#pet"));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!open && !menu && brain.bubble && (
|
{!open && !menu && !chatOpen && brain.bubble && (
|
||||||
<SagiFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
<SpeechBubble
|
||||||
|
bubble={brain.bubble}
|
||||||
|
onDismiss={brain.dismissBubble}
|
||||||
|
onOpen={openSession}
|
||||||
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -368,34 +496,47 @@ function SagiBody() {
|
|||||||
onPointerEnter={() => setHover(true)}
|
onPointerEnter={() => setHover(true)}
|
||||||
onPointerLeave={() => setHover(false)}
|
onPointerLeave={() => setHover(false)}
|
||||||
onPointerDown={place.onPointerDown}
|
onPointerDown={place.onPointerDown}
|
||||||
onPointerMove={place.onPointerMove}
|
onPointerMove={(e) => {
|
||||||
|
place.onPointerMove(e);
|
||||||
|
onStroke(e);
|
||||||
|
}}
|
||||||
onPointerUp={place.onPointerUp}
|
onPointerUp={place.onPointerUp}
|
||||||
onKeyDown={place.onKeyDown}
|
onKeyDown={place.onKeyDown}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// A drag just ended - swallow the synthetic click so the panel
|
// A drag just ended - swallow the synthetic click so the panel
|
||||||
// doesn't toggle when the user only repositioned the avatar.
|
// doesn't toggle when the user only repositioned the avatar.
|
||||||
if (place.consumeDrag()) return;
|
if (place.consumeDrag()) return;
|
||||||
setOpen((v) => !v);
|
if (chatOpen) setChatOpen(false);
|
||||||
|
else setOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setChatOpen(false);
|
||||||
setMenu(true);
|
setMenu(true);
|
||||||
}}
|
}}
|
||||||
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
|
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||||
aria-expanded={open}
|
aria-expanded={open || chatOpen}
|
||||||
title={t("sagi.avatarTitle")}
|
title={t("sagi.avatarTitle", { name })}
|
||||||
>
|
>
|
||||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
{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
|
<PetSprite
|
||||||
id={pet.id}
|
id={pet.id}
|
||||||
kind={pet.kind}
|
kind={pet.kind}
|
||||||
mood={brain.mood}
|
mood={brain.mood}
|
||||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
// 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}
|
gaze={gaze}
|
||||||
size={size}
|
size={size}
|
||||||
reducedMotion={reducedMotion}
|
reducedMotion={reducedMotion}
|
||||||
@@ -403,10 +544,28 @@ function SagiBody() {
|
|||||||
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{brain.status.errorCount > 0 && (
|
{brain.reaction && (
|
||||||
|
<span key={brain.reaction.key} className="sagi-reaction" aria-live="polite">
|
||||||
|
<span className="sagi-hearts" aria-hidden>
|
||||||
|
<span>💜</span>
|
||||||
|
<span>💗</span>
|
||||||
|
<span>💜</span>
|
||||||
|
</span>
|
||||||
|
<span className="sagi-reaction-text" data-side={place.side}>
|
||||||
|
{brain.reaction.text}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{brain.status.errorCount > 0 ? (
|
||||||
<span className="sagi-error-dot" aria-hidden>
|
<span className="sagi-error-dot" aria-hidden>
|
||||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -37,12 +37,14 @@
|
|||||||
* ============================================================================= */
|
* ============================================================================= */
|
||||||
|
|
||||||
import { memo, useEffect, useRef } from "react";
|
import { memo, useEffect, useRef } from "react";
|
||||||
import type { Mood } from "./brain";
|
import type { Mood, PetMoment } from "./brain";
|
||||||
|
|
||||||
interface SagiAvatarProps {
|
interface SagiAvatarProps {
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
reducedMotion: boolean;
|
reducedMotion: boolean;
|
||||||
size?: number;
|
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
|
const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space
|
||||||
@@ -88,6 +90,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
|||||||
mood,
|
mood,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
size = 68,
|
size = 68,
|
||||||
|
moment = null,
|
||||||
}: SagiAvatarProps) {
|
}: SagiAvatarProps) {
|
||||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||||
const rafRef = useRef<number>();
|
const rafRef = useRef<number>();
|
||||||
@@ -148,6 +151,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
|||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="sagi-avatar"
|
className="sagi-avatar"
|
||||||
data-mood={mood}
|
data-mood={mood}
|
||||||
|
data-moment={moment ?? undefined}
|
||||||
data-reduced={reducedMotion ? "1" : "0"}
|
data-reduced={reducedMotion ? "1" : "0"}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
|
|||||||
@@ -50,9 +50,6 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Play,
|
|
||||||
Activity,
|
|
||||||
LayoutList,
|
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -61,12 +58,30 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
Radio,
|
Radio,
|
||||||
|
Heart,
|
||||||
|
ChevronRight,
|
||||||
|
MessageCircle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
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;
|
status: SagiStatus;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
onToggleMute: () => void;
|
onToggleMute: () => void;
|
||||||
@@ -74,21 +89,38 @@ interface SagiPanelProps {
|
|||||||
onNavigate: (route: string) => void;
|
onNavigate: (route: string) => void;
|
||||||
/** Returns an answer to display, or null when the query was handed off. */
|
/** Returns an answer to display, or null when the query was handed off. */
|
||||||
onAsk: (query: string) => string | null;
|
onAsk: (query: string) => string | null;
|
||||||
|
/** Opens the chat window (PetChat). */
|
||||||
|
onOpenChat: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SagiPanel({
|
export function SagiPanel({
|
||||||
|
name,
|
||||||
|
ownName,
|
||||||
|
nickname,
|
||||||
|
onRename,
|
||||||
|
onPat,
|
||||||
|
attention,
|
||||||
|
onOpenSession,
|
||||||
status,
|
status,
|
||||||
muted,
|
muted,
|
||||||
onToggleMute,
|
onToggleMute,
|
||||||
onClearAlerts,
|
onClearAlerts,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onAsk,
|
onAsk,
|
||||||
|
onOpenChat,
|
||||||
onClose,
|
onClose,
|
||||||
}: SagiPanelProps) {
|
}: SagiPanelProps) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [answer, setAnswer] = useState<string | null>(null);
|
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) => {
|
const submit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -101,7 +133,7 @@ export function SagiPanel({
|
|||||||
<div
|
<div
|
||||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
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"
|
role="dialog"
|
||||||
aria-label={t("sagi.panel.label")}
|
aria-label={t("sagi.panel.label", { name })}
|
||||||
>
|
>
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
<div className="flex items-center 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 +141,40 @@ export function SagiPanel({
|
|||||||
<span className="text-base leading-none" aria-hidden>
|
<span className="text-base leading-none" aria-hidden>
|
||||||
🐾
|
🐾
|
||||||
</span>
|
</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
|
<span
|
||||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
status.connected
|
status.connected
|
||||||
@@ -126,13 +191,23 @@ export function SagiPanel({
|
|||||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex shrink-0 items-center">
|
||||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
<button
|
||||||
onClick={onClose}
|
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||||
aria-label={t("sagi.panel.close")}
|
onClick={onPat}
|
||||||
>
|
aria-label={t("sagi.panel.pat", { name })}
|
||||||
<X size={15} />
|
title={t("sagi.panel.pat", { name })}
|
||||||
</button>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
@@ -158,29 +233,62 @@ export function SagiPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* quick actions */}
|
||||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||||
<ActionButton
|
<div className="col-span-2">
|
||||||
icon={Play}
|
<ActionButton
|
||||||
label={t("sagi.panel.runClaude")}
|
icon={MessageCircle}
|
||||||
onClick={() => onNavigate("/workspace")}
|
label={t("sagi.panel.chat", { name })}
|
||||||
/>
|
onClick={onOpenChat}
|
||||||
<ActionButton
|
/>
|
||||||
icon={Activity}
|
</div>
|
||||||
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
|
<ActionButton
|
||||||
icon={muted ? BellOff : Bell}
|
icon={muted ? BellOff : Bell}
|
||||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||||
@@ -198,10 +306,10 @@ export function SagiPanel({
|
|||||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||||
<input
|
<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"
|
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}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
aria-label={t("sagi.panel.ask")}
|
aria-label={t("sagi.panel.ask", { name })}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -284,7 +392,7 @@ function ActionButton({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<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}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* @file SpeechBubble.tsx
|
* @file SpeechBubble.tsx
|
||||||
* @description Transient speech bubble rendered above the Sagi mascot.
|
* @description Transient speech bubble rendered above the Sagi mascot.
|
||||||
* Pure presentation — visibility timing and quip selection live in
|
* Pure presentation — visibility timing, coalescing and quip selection live in
|
||||||
* {@link useSagiBrain}; this component only paints the bubble and handles
|
* {@link useSagiBrain}; this component paints the line, the session(s) it is
|
||||||
* user dismissal.
|
* about, an Open button when it is about exactly one, and handles dismissal.
|
||||||
*
|
*
|
||||||
* ## Accessibility
|
* ## Accessibility
|
||||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
* new quips without interrupting current speech. Click the bubble to dismiss
|
||||||
* to dismiss early.
|
* it early; its Open button opens the session instead.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -52,30 +52,52 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { SagiBubble } from "./useSagiBrain";
|
||||||
|
import type { SagiSubject } from "./brain";
|
||||||
|
|
||||||
/** Props for {@link SpeechBubble}. */
|
/** Props for {@link SpeechBubble}. */
|
||||||
interface SpeechBubbleProps {
|
interface SpeechBubbleProps {
|
||||||
/** Quip text to display inside the bubble. */
|
bubble: SagiBubble;
|
||||||
text: string;
|
|
||||||
/** Called when the user clicks to dismiss. */
|
/** Called when the user clicks to dismiss. */
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
|
/** Opens the session the bubble is about. */
|
||||||
|
onOpen?: (subject: SagiSubject) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Animated speech bubble above Sagi.
|
* Animated speech bubble above Sagi.
|
||||||
* @param props See {@link SpeechBubbleProps}.
|
* @param props See {@link SpeechBubbleProps}.
|
||||||
*/
|
*/
|
||||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
export function SpeechBubble({ bubble, onDismiss, onOpen }: SpeechBubbleProps) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
const { text, detail, subject, tone } = bubble;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="sagi-bubble sagi-bubble-enter"
|
className="sagi-bubble sagi-bubble-enter"
|
||||||
|
data-tone={tone}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
title={t("sagi.bubbleDismiss")}
|
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>
|
</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();
|
renderMenu();
|
||||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
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");
|
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ describe("PetMenu", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||||
await waitFor(() =>
|
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);
|
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 () => {
|
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||||
render(<PetSettings />);
|
render(<PetSettings />);
|
||||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
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");
|
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
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"));
|
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||||
answerConfirm(true);
|
answerConfirm(true);
|
||||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
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" });
|
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||||
await waitFor(() =>
|
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,89 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names the errored session in its bubble and in the panel", () => {
|
||||||
|
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("opens the chat from the panel and goes back to it", () => {
|
||||||
|
renderSagi();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /chat with sagi/i }));
|
||||||
|
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("reflects the live count in the panel status", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|||||||
@@ -225,6 +225,43 @@ describe("clearErrors", () => {
|
|||||||
expect(statusOf(s).liveCount).toBe(1);
|
expect(statusOf(s).liveCount).toBe(1);
|
||||||
expect(s.worriedUntil).toBe(0);
|
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.
|
// 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());
|
const r = matchIntent("Refactor my auth module", status());
|
||||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
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`). */
|
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
||||||
export type Mood =
|
export type Mood =
|
||||||
| "disconnected"
|
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
|
||||||
| "worried"
|
|
||||||
| "stuck"
|
|
||||||
| "happy"
|
|
||||||
| "thinking"
|
|
||||||
| "watching"
|
|
||||||
| "sleeping"
|
|
||||||
| "idle";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||||
@@ -152,13 +145,21 @@ export type Mood =
|
|||||||
* was irrelevant or non-notable.
|
* was irrelevant or non-notable.
|
||||||
*/
|
*/
|
||||||
export type SagiPulse =
|
export type SagiPulse =
|
||||||
| "session_done"
|
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
|
||||||
| "session_start"
|
|
||||||
| "subagent_spawn"
|
/** A short animation the pet plays on an event or a pat, over its mood. */
|
||||||
| "waiting"
|
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
|
||||||
| "error"
|
|
||||||
| "run_done"
|
/** Which moment each pulse plays: greet a new session, hop at a finish,
|
||||||
| null;
|
* 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 {
|
export interface SagiStatus {
|
||||||
/** Active + waiting sessions (everything not finished/errored). */
|
/** Active + waiting sessions (everything not finished/errored). */
|
||||||
@@ -182,6 +183,47 @@ export interface SagiState {
|
|||||||
worriedUntil: number;
|
worriedUntil: number;
|
||||||
/** True while an Ask request is in flight (panel). */
|
/** True while an Ask request is in flight (panel). */
|
||||||
thinking: boolean;
|
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).
|
// Tunable timing constants (ms).
|
||||||
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
|
|||||||
happyUntil: 0,
|
happyUntil: 0,
|
||||||
worriedUntil: 0,
|
worriedUntil: 0,
|
||||||
thinking: false,
|
thinking: false,
|
||||||
|
dismissed: {},
|
||||||
|
info: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,16 +300,28 @@ export function reduceSagi(
|
|||||||
state: SagiState,
|
state: SagiState,
|
||||||
msg: WSMessage,
|
msg: WSMessage,
|
||||||
now: number
|
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) {
|
switch (msg.type) {
|
||||||
case "session_created":
|
case "session_created":
|
||||||
case "session_updated": {
|
case "session_updated": {
|
||||||
const s = msg.data as Session;
|
const s = msg.data as Session;
|
||||||
if (!s || !s.id) return { state, pulse: null };
|
if (!s || !s.id) return { state, pulse: null };
|
||||||
const sessions = { ...state.sessions };
|
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 pulse: SagiPulse = null;
|
||||||
let happyUntil = state.happyUntil;
|
let happyUntil = state.happyUntil;
|
||||||
let worriedUntil = state.worriedUntil;
|
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") {
|
if (s.status === "active") {
|
||||||
// "waiting" = active session blocked on user input (permission prompt
|
// "waiting" = active session blocked on user input (permission prompt
|
||||||
@@ -280,12 +336,20 @@ export function reduceSagi(
|
|||||||
if (prev === undefined) pulse = "session_start";
|
if (prev === undefined) pulse = "session_start";
|
||||||
}
|
}
|
||||||
} else if (s.status === "error") {
|
} 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";
|
sessions[s.id] = "error";
|
||||||
worriedUntil = now + WORRIED_MS;
|
|
||||||
pulse = "error";
|
|
||||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||||
const wasTracked = s.id in sessions;
|
const wasTracked = s.id in sessions;
|
||||||
delete sessions[s.id];
|
delete sessions[s.id];
|
||||||
|
delete info[s.id];
|
||||||
if (s.status === "completed") {
|
if (s.status === "completed") {
|
||||||
happyUntil = now + HAPPY_MS;
|
happyUntil = now + HAPPY_MS;
|
||||||
if (wasTracked) pulse = "session_done";
|
if (wasTracked) pulse = "session_done";
|
||||||
@@ -293,31 +357,46 @@ export function reduceSagi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
state: {
|
||||||
|
...state,
|
||||||
|
sessions,
|
||||||
|
dismissed,
|
||||||
|
info,
|
||||||
|
happyUntil,
|
||||||
|
worriedUntil,
|
||||||
|
lastActivityAt: now,
|
||||||
|
},
|
||||||
pulse,
|
pulse,
|
||||||
|
subject,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "agent_created": {
|
case "agent_created": {
|
||||||
const a = msg.data as Agent;
|
const a = msg.data as Agent;
|
||||||
if (a && a.status === "error") {
|
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||||
return {
|
return {
|
||||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||||
pulse: "error",
|
pulse: "error",
|
||||||
|
subject: known(a.session_id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||||
// is already covered by session_start.
|
// is already covered by session_start.
|
||||||
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
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": {
|
case "agent_updated": {
|
||||||
const a = msg.data as Agent;
|
const a = msg.data as Agent;
|
||||||
if (a && a.status === "error") {
|
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||||
return {
|
return {
|
||||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||||
pulse: "error",
|
pulse: "error",
|
||||||
|
subject: known(a.session_id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||||
@@ -325,7 +404,8 @@ export function reduceSagi(
|
|||||||
|
|
||||||
case "new_event": {
|
case "new_event": {
|
||||||
const e = msg.data as DashboardEvent;
|
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 {
|
return {
|
||||||
state: {
|
state: {
|
||||||
...state,
|
...state,
|
||||||
@@ -333,6 +413,7 @@ export function reduceSagi(
|
|||||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||||
},
|
},
|
||||||
pulse: isFailure ? "error" : null,
|
pulse: isFailure ? "error" : null,
|
||||||
|
subject: isFailure ? known(e.session_id) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,24 +442,60 @@ export function reduceSagi(
|
|||||||
*/
|
*/
|
||||||
export function seedSessions(
|
export function seedSessions(
|
||||||
state: SagiState,
|
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
|
now: number
|
||||||
): SagiState {
|
): SagiState {
|
||||||
const sessions = { ...state.sessions };
|
const sessions = { ...state.sessions };
|
||||||
|
const info = { ...state.info };
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!r || !r.id) continue;
|
if (!r || !r.id) continue;
|
||||||
if (r.status === "error") sessions[r.id] = "error";
|
if (r.status === "error") {
|
||||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
if (r.id in state.dismissed) continue;
|
||||||
// completed / abandoned: leave untracked.
|
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 {
|
export function clearErrors(state: SagiState): SagiState {
|
||||||
const sessions: SagiState["sessions"] = {};
|
const sessions: SagiState["sessions"] = {};
|
||||||
|
const dismissed = { ...state.dismissed };
|
||||||
|
const info = { ...state.info };
|
||||||
for (const [id, s] of Object.entries(state.sessions)) {
|
for (const [id, s] of Object.entries(state.sessions)) {
|
||||||
if (s !== "error") sessions[id] = s;
|
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
|
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
||||||
* small set of intents answerable from cached dashboard status. Anything it
|
* 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`).
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
* - `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.
|
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## 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 t = (key: string, vars?: Record<string, unknown>) =>
|
||||||
const q = query.trim().toLowerCase();
|
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
|
||||||
if (!q) {
|
|
||||||
return {
|
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
|
||||||
kind: "answer",
|
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
|
||||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
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));
|
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")) {
|
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
|
||||||
return {
|
return { kind: "answer", text: t("help", { name }) };
|
||||||
kind: "answer",
|
}
|
||||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
|
||||||
};
|
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.
|
// 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 {
|
return {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
text:
|
text:
|
||||||
status.errorCount > 0
|
status.errorCount > 0
|
||||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
|
||||||
: "no errors - all clean 🐾",
|
: 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 {
|
return {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
text:
|
text:
|
||||||
status.waitingCount > 0
|
status.waitingCount > 0
|
||||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
|
||||||
: "nothing's waiting on you right now 🐾",
|
: t("waitingNone"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
if (
|
||||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
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 {
|
return {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
text:
|
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
|
||||||
status.liveCount > 0
|
|
||||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
|
||||||
: "nothing's running right now - all quiet.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
text: t("status", {
|
||||||
status.connected ? "connected" : "offline"
|
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() };
|
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 POS_KEY = "agent-dashboard-sagi-pos";
|
||||||
const PET_KEY = "agenthub.pet";
|
const PET_KEY = "agenthub.pet";
|
||||||
const SIZE_KEY = "agenthub.pet.size";
|
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. */
|
/** Sagi's own size, and the slider's range. */
|
||||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||||
|
|
||||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||||
* The kind rides along so the mascot renders without a fetch. */
|
* The kind (and its display name) ride along so the mascot renders, and
|
||||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
* introduces itself, without a fetch. */
|
||||||
|
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
|
||||||
const SAGI: PetChoice = { id: "sagi" };
|
const SAGI: PetChoice = { id: "sagi" };
|
||||||
|
|
||||||
function readPet(): PetChoice {
|
function readPet(): PetChoice {
|
||||||
@@ -69,6 +76,7 @@ function readPet(): PetChoice {
|
|||||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
name: string;
|
||||||
}> | null;
|
}> | null;
|
||||||
if (
|
if (
|
||||||
p &&
|
p &&
|
||||||
@@ -76,13 +84,34 @@ function readPet(): PetChoice {
|
|||||||
p.id !== "sagi" &&
|
p.id !== "sagi" &&
|
||||||
(p.kind === "codex" || p.kind === "image")
|
(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 {
|
} catch {
|
||||||
/* unreadable: Sagi */
|
/* unreadable: Sagi */
|
||||||
}
|
}
|
||||||
return 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 {
|
function readSize(): number {
|
||||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||||
return Number.isFinite(n) && n > 0
|
return Number.isFinite(n) && n > 0
|
||||||
@@ -181,6 +210,37 @@ export const sagiPrefs = {
|
|||||||
SIZE_KEY,
|
SIZE_KEY,
|
||||||
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
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 to any pref change; returns an unsubscribe fn. */
|
||||||
subscribe(handler: () => void): () => void {
|
subscribe(handler: () => void): () => void {
|
||||||
const listener = () => handler();
|
const listener = () => handler();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file quips.ts
|
* @file quips.ts
|
||||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
|
* @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
|
* (and greetings, pat reactions), read from the `settings:sagi.*` i18n arrays
|
||||||
* can be unit-tested without randomness leaking in.
|
* so they follow the UI language, plus a deterministic-by-injection picker.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -24,10 +24,13 @@
|
|||||||
*
|
*
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
|
* - `../../i18n`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `pickQuip` — 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.
|
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
@@ -59,76 +62,60 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import i18n from "../../i18n";
|
||||||
import type { Mood, SagiPulse } from "./brain";
|
import type { Mood, SagiPulse } from "./brain";
|
||||||
|
|
||||||
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
||||||
|
|
||||||
const QUIPS: Record<QuipKey, string[]> = {
|
export const ALL_QUIP_KEYS: QuipKey[] = [
|
||||||
// Pulses (event-driven, transient bubbles)
|
// Pulses (event-driven, transient bubbles)
|
||||||
session_done: [
|
"session_done",
|
||||||
"a session just wrapped up! 🐾",
|
"session_start",
|
||||||
"a session finished - nice work! ✨",
|
"subagent_spawn",
|
||||||
"that session's all done 😺",
|
"waiting",
|
||||||
"clean finish on that one 💜",
|
"error",
|
||||||
],
|
"run_done",
|
||||||
session_start: [
|
// Moods (steady-state flavor)
|
||||||
"a new session started! 👀",
|
"disconnected",
|
||||||
"a fresh session just landed 🐾",
|
"worried",
|
||||||
"ooh, a new session to watch 😻",
|
"stuck",
|
||||||
"something new is cooking 🍲",
|
"happy",
|
||||||
],
|
"thinking",
|
||||||
subagent_spawn: [
|
"watching",
|
||||||
"a subagent just spawned! 🐾",
|
"sleeping",
|
||||||
"a little helper joined in 🤝",
|
"idle",
|
||||||
"a subagent's on the job 🚀",
|
];
|
||||||
"reinforcements - new subagent! 😺",
|
const KNOWN = new Set<string>(ALL_QUIP_KEYS);
|
||||||
],
|
|
||||||
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 ✨"],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
* Pick one line from an i18n string array under `settings:sagi.<path>`.
|
||||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
* `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 {
|
export function pickLine(
|
||||||
const pool = QUIPS[key];
|
path: string,
|
||||||
if (!pool || pool.length === 0) return "";
|
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)));
|
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.
|
* sagi.css - animations + mood expressions for the Sagi companion.
|
||||||
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
|
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
|
||||||
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
|
* with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
|
||||||
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
|
* 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
|
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -206,20 +210,20 @@
|
|||||||
|
|
||||||
/* idle: gentle breathing + occasional blink */
|
/* idle: gentle breathing + occasional blink */
|
||||||
.sagi-avatar[data-mood="idle"] {
|
.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
|
/* 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
|
eye-tracking translate (which would freeze the eyes - see the CSS-blink
|
||||||
note in SagiAvatar.tsx). */
|
note in SagiAvatar.tsx). */
|
||||||
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
|
.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 */
|
/* happy: whole-mascot bob + twinkling sparkle */
|
||||||
.sagi-avatar[data-mood="happy"] {
|
.sagi-avatar[data-mood="happy"] {
|
||||||
animation: sagi-bob 0.5s ease-in-out 0s 4;
|
animation: sagi-bob 0.5s ease-in-out 0s 4;
|
||||||
}
|
}
|
||||||
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
|
.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 */
|
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
|
||||||
}
|
}
|
||||||
/* worried: alarmed shake */
|
/* worried: alarmed shake */
|
||||||
@@ -228,19 +232,97 @@
|
|||||||
}
|
}
|
||||||
/* stuck: slow, drooping breathe (fatigued) */
|
/* stuck: slow, drooping breathe (fatigued) */
|
||||||
.sagi-avatar[data-mood="stuck"] {
|
.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 */
|
/* thinking: rocking head tilt */
|
||||||
.sagi-avatar[data-mood="thinking"] {
|
.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 */
|
/* watching: attentive sway while a session is live */
|
||||||
.sagi-avatar[data-mood="watching"] {
|
.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 */
|
/* sleeping: slow breathe, droop */
|
||||||
.sagi-avatar[data-mood="sleeping"] {
|
.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 ── */
|
/* ── reduced motion: kill all continuous animation ── */
|
||||||
@@ -339,7 +421,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 2px solid #0c0c14;
|
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 {
|
.sagi-bubble {
|
||||||
max-width: 16rem;
|
max-width: 16rem;
|
||||||
@@ -356,6 +446,41 @@
|
|||||||
.sagi-bubble-enter {
|
.sagi-bubble-enter {
|
||||||
animation: sagi-bubble-in 0.22s ease-out;
|
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 {
|
@keyframes sagi-bubble-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -375,7 +500,7 @@
|
|||||||
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
/* 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. */
|
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
||||||
.sagi-pet-image {
|
.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;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
.sagi-pet-image[data-mood="thinking"] {
|
.sagi-pet-image[data-mood="thinking"] {
|
||||||
@@ -403,3 +528,165 @@
|
|||||||
.sagi-flyout[data-front="1"] {
|
.sagi-flyout[data-front="1"] {
|
||||||
z-index: 42;
|
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) =>
|
const install = (featuredId: string) =>
|
||||||
run(featuredId, async () => {
|
run(featuredId, async () => {
|
||||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
* @file useSagiBrain.ts
|
* @file useSagiBrain.ts
|
||||||
* @description React hook that wires the pure Sagi brain to the live event bus
|
* @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
|
* 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
|
* exposes the derived mood, a status summary, the sessions that need the user,
|
||||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -62,127 +66,303 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import i18n from "../../i18n";
|
||||||
import type { WSMessage } from "../../lib/types";
|
import type { WSMessage } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
initialSagiState,
|
initialSagiState,
|
||||||
reduceSagi,
|
reduceSagi,
|
||||||
deriveMood,
|
deriveMood,
|
||||||
statusOf,
|
statusOf,
|
||||||
|
attentionOf,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
seedSessions,
|
seedSessions,
|
||||||
|
subjectLabel,
|
||||||
|
MOMENT_OF_PULSE,
|
||||||
|
type AttentionItem,
|
||||||
type Mood,
|
type Mood,
|
||||||
|
type PetMoment,
|
||||||
|
type SagiPulse,
|
||||||
type SagiState,
|
type SagiState,
|
||||||
type SagiStatus,
|
type SagiStatus,
|
||||||
|
type SagiSubject,
|
||||||
} from "./brain";
|
} from "./brain";
|
||||||
import { pickQuip } from "./quips";
|
import { greetingKey, pickLine, pickQuip } from "./quips";
|
||||||
|
import { statusSummary } from "./intents";
|
||||||
import { sagiPrefs } from "./prefs";
|
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;
|
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.
|
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||||
const BUBBLE_THROTTLE_MS = 3000;
|
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 {
|
export interface SagiBrain {
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
status: SagiStatus;
|
status: SagiStatus;
|
||||||
bubble: string | null;
|
/** Errored, then waiting sessions, named. */
|
||||||
|
attention: AttentionItem[];
|
||||||
|
bubble: SagiBubble | null;
|
||||||
dismissBubble: () => void;
|
dismissBubble: () => void;
|
||||||
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
||||||
* errors). Bypasses the throttle because the caller decided this message
|
* errors). Bypasses the throttle because the caller decided this message
|
||||||
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
||||||
setBubble: (text: string) => void;
|
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;
|
muted: boolean;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
clearAlerts: () => void;
|
clearAlerts: () => void;
|
||||||
setThinking: (v: boolean) => 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 {
|
export function useSagiBrain(): SagiBrain {
|
||||||
const now0 = Date.now();
|
const now0 = Date.now();
|
||||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
// 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.
|
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||||
const [state, setState] = useState<SagiState>(() => ({
|
const [state, setStateRaw] = useState<SagiState>(() => ({
|
||||||
...initialSagiState(now0),
|
...initialSagiState(now0),
|
||||||
connected: true,
|
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 [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 [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||||
|
|
||||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const lastBubbleAt = useRef(0);
|
const lastBubbleAt = useRef(0);
|
||||||
|
const burst = useRef<Burst | null>(null);
|
||||||
|
const momentKey = useRef(0);
|
||||||
const mutedRef = useRef(muted);
|
const mutedRef = useRef(muted);
|
||||||
mutedRef.current = 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.
|
// Keep mute in sync with the Settings page / other tabs.
|
||||||
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
||||||
|
|
||||||
const showBubble = useCallback((text: string, force: boolean) => {
|
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
|
||||||
if (!text) return;
|
|
||||||
if (mutedRef.current) return;
|
|
||||||
const t = Date.now();
|
|
||||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
|
||||||
lastBubbleAt.current = t;
|
|
||||||
clearTimeout(bubbleTimer.current);
|
clearTimeout(bubbleTimer.current);
|
||||||
setBubbleState(text);
|
setBubbleState(b);
|
||||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
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 -
|
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||||
// the brain otherwise only learns about sessions from WS deltas that arrive
|
// 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.
|
// 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.
|
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||||
|
// Then say hello, once per browser session.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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
|
api.sessions
|
||||||
.list({ status: "active", limit: 100 })
|
.list({ status: "active", limit: 100 })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
clearTimeout(greetTimer);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [update, putBubble, playMoment]);
|
||||||
|
|
||||||
// Subscribe to the live stream and connection status.
|
// Subscribe to the live stream and connection status.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||||
const t = Date.now();
|
const prev = stateRef.current;
|
||||||
setState((prev) => {
|
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||||
const { state: next, pulse } = reduceSagi(prev, msg, t);
|
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
// resolution: keep the old state then, so a hook burst (several frames a
|
||||||
return next;
|
// 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) => {
|
const unsubConn = eventBus.onConnection((connected) => {
|
||||||
setState((prev) => ({ ...prev, connected }));
|
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unsubMsg();
|
unsubMsg();
|
||||||
unsubConn();
|
unsubConn();
|
||||||
};
|
};
|
||||||
}, [showBubble]);
|
}, [update, notify]);
|
||||||
|
|
||||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||||
// happy/worried) re-evaluate without needing a new event.
|
// happy/worried) re-evaluate without needing a new event.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
// 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(() => {
|
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);
|
}, 1000);
|
||||||
return () => clearInterval(id);
|
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 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(() => {
|
const dismissBubble = useCallback(() => {
|
||||||
clearTimeout(bubbleTimer.current);
|
clearTimeout(bubbleTimer.current);
|
||||||
setBubbleState(null);
|
setBubbleState(null);
|
||||||
|
burst.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
@@ -192,29 +372,47 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
if (next) dismissBubble();
|
if (next) dismissBubble();
|
||||||
}, [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(
|
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
|
// 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,
|
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
||||||
// bypassing the throttled mood-driven showBubble path.
|
// bypassing the throttled mood-driven showBubble path.
|
||||||
const setBubble = useCallback((text: string) => {
|
const setBubble = useCallback(
|
||||||
if (!text) return;
|
(text: string) => {
|
||||||
clearTimeout(bubbleTimer.current);
|
if (!text) return;
|
||||||
setBubbleState(text);
|
burst.current = null;
|
||||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
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 {
|
return {
|
||||||
mood,
|
mood,
|
||||||
status,
|
status,
|
||||||
|
attention,
|
||||||
bubble,
|
bubble,
|
||||||
dismissBubble,
|
dismissBubble,
|
||||||
setBubble,
|
setBubble,
|
||||||
|
moment,
|
||||||
|
playMoment,
|
||||||
|
pat,
|
||||||
|
reaction,
|
||||||
muted,
|
muted,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
clearAlerts,
|
clearAlerts,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sidebar.tsx
|
* @file Sidebar.tsx
|
||||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -49,12 +49,14 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
|
import { settingsHref } from "../lib/settingsLink";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
HardDrive,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Settings,
|
Settings,
|
||||||
Wifi,
|
Wifi,
|
||||||
@@ -87,6 +89,8 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
|||||||
const NAV_KEYS = [
|
const NAV_KEYS = [
|
||||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||||
|
// The file manager (FilesPage): browse, upload and download workspace files.
|
||||||
|
{ to: "/files", icon: HardDrive, key: "nav:files" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const STORAGE_KEY = "sidebar-collapsed";
|
const STORAGE_KEY = "sidebar-collapsed";
|
||||||
@@ -186,6 +190,12 @@ export function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
|
||||||
|
// link's own match (the path) cannot tell when it is the one on screen.
|
||||||
|
const location = useLocation();
|
||||||
|
const settingsOn =
|
||||||
|
location.pathname.startsWith("/settings") ||
|
||||||
|
new URLSearchParams(location.search).has("settings");
|
||||||
// Checking for (and applying) an update acts on the host: an admin's.
|
// Checking for (and applying) an update acts on the host: an admin's.
|
||||||
const canUpdate = usePermissions().isAdmin;
|
const canUpdate = usePermissions().isAdmin;
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
const asideRef = useRef<HTMLElement>(null);
|
||||||
@@ -533,9 +543,9 @@ export function Sidebar({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to={settingsHref("", location)}
|
||||||
title={collapsed ? t("nav:settings") : undefined}
|
title={collapsed ? t("nav:settings") : undefined}
|
||||||
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
|
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||||
@@ -545,7 +555,7 @@ export function Sidebar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void auth.logout()}
|
onClick={() => void auth.logout()}
|
||||||
title={`${t("common:auth.signedInAs", {
|
title={`${t("common:auth.signedInAs", {
|
||||||
name: auth.state.user.username,
|
name: auth.state.user.displayName || auth.state.user.username,
|
||||||
role: auth.state.user.role,
|
role: auth.state.user.role,
|
||||||
})} — ${t("common:auth.logout")}`}
|
})} — ${t("common:auth.logout")}`}
|
||||||
aria-label={t("common:auth.logout")}
|
aria-label={t("common:auth.logout")}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @file SplashScreen.tsx
|
* @file SplashScreen.tsx
|
||||||
* @description Branding splash shown once per browser session on app load. A
|
* @description Branding splash shown once per browser session on app load. A
|
||||||
* dark-tech "constellation" overlay built around the node-graph brand mark:
|
* dark-tech "constellation" overlay built around the SmartGift brand mark:
|
||||||
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
|
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
|
||||||
* in a staggered cascade. The tagline and the subtext pair are picked at
|
* in a staggered cascade. The tagline and the subtext pair are picked at
|
||||||
* random (per mount) from localized pools in `splash.json`, so the copy is
|
* random (per mount) from localized pools in `splash.json`, so the copy is
|
||||||
@@ -143,7 +143,7 @@ export function SplashScreen() {
|
|||||||
<div className="splash-grain" aria-hidden="true" />
|
<div className="splash-grain" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="splash-content">
|
<div className="splash-content">
|
||||||
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
|
{/* Brand mark - the SmartGift logo */}
|
||||||
<div className="splash-mark" aria-hidden="true">
|
<div className="splash-mark" aria-hidden="true">
|
||||||
<span className="splash-mark-glow" />
|
<span className="splash-mark-glow" />
|
||||||
<BrandMark />
|
<BrandMark />
|
||||||
@@ -167,74 +167,10 @@ export function SplashScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
|
/** The SmartGift mark — the same file as the favicon, local so the splash
|
||||||
* with animated connector lines and pulsing outer nodes. */
|
* needs no network. */
|
||||||
function BrandMark() {
|
function BrandMark() {
|
||||||
return (
|
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
|
||||||
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stopColor="#6366f1" />
|
|
||||||
<stop offset="100%" stopColor="#818cf8" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
|
|
||||||
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<polygon
|
|
||||||
className="splash-hex"
|
|
||||||
points="16,2 28,9 28,23 16,30 4,23 4,9"
|
|
||||||
fill="url(#splashBg)"
|
|
||||||
/>
|
|
||||||
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
|
|
||||||
<line
|
|
||||||
className="splash-line"
|
|
||||||
x1="16"
|
|
||||||
y1="13"
|
|
||||||
x2="16"
|
|
||||||
y2="7"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
className="splash-line splash-line-2"
|
|
||||||
x1="18.6"
|
|
||||||
y1="17.5"
|
|
||||||
x2="24"
|
|
||||||
y2="20.5"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
className="splash-line splash-line-3"
|
|
||||||
x1="13.4"
|
|
||||||
y1="17.5"
|
|
||||||
x2="8"
|
|
||||||
y2="20.5"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
|
|
||||||
<circle
|
|
||||||
className="splash-node splash-node-2"
|
|
||||||
cx="24.5"
|
|
||||||
cy="21"
|
|
||||||
r="1.8"
|
|
||||||
fill="url(#splashGlow)"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
className="splash-node splash-node-3"
|
|
||||||
cx="7.5"
|
|
||||||
cy="21"
|
|
||||||
r="1.8"
|
|
||||||
fill="url(#splashGlow)"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Faint background constellation: a handful of nodes joined by thin lines that
|
/** Faint background constellation: a handful of nodes joined by thin lines that
|
||||||
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
|
|||||||
animation: splashGlowPulse 2.6s ease-in-out infinite;
|
animation: splashGlowPulse 2.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
|
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
|
||||||
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
|
|
||||||
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
|
|
||||||
.splash-line-2 { animation-delay: 0.62s; }
|
|
||||||
.splash-line-3 { animation-delay: 0.74s; }
|
|
||||||
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
|
|
||||||
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
|
|
||||||
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
|
|
||||||
|
|
||||||
.splash-greeting {
|
.splash-greeting {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
|
|||||||
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
|
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
|
||||||
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
|
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
|
||||||
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||||
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
|
|
||||||
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
|
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
|
||||||
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
|
|
||||||
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
|
|
||||||
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
|
||||||
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
|
|||||||
.splash-star,
|
.splash-star,
|
||||||
.splash-mark,
|
.splash-mark,
|
||||||
.splash-mark-glow,
|
.splash-mark-glow,
|
||||||
.splash-hex,
|
|
||||||
.splash-line,
|
|
||||||
.splash-node,
|
|
||||||
.splash-dot,
|
.splash-dot,
|
||||||
.splash-greeting,
|
.splash-greeting,
|
||||||
.splash-tagline,
|
.splash-tagline,
|
||||||
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
|
|||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
stroke-dashoffset: 0 !important;
|
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
* 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
|
* 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
|
* 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
|
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
* 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 (and attached run) the Workspace page shows, from its URL. */
|
||||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
/** 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) {
|
export function selectedFrom(pathname: string, search: string) {
|
||||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||||
const p = new URLSearchParams(search);
|
const p = new URLSearchParams(search);
|
||||||
@@ -150,7 +154,7 @@ export function WorkspaceTree() {
|
|||||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
// 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);
|
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
|
||||||
return [...list].sort(
|
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]);
|
}, [lanes, order, toolFilter, grouped, meId]);
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -338,8 +342,8 @@ export function WorkspaceTree() {
|
|||||||
lane.owner_id == null
|
lane.owner_id == null
|
||||||
? t("tree.noOwner")
|
? t("tree.noOwner")
|
||||||
: lane.owner_id === meId
|
: lane.owner_id === meId
|
||||||
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
|
? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` })
|
||||||
: (lane.owner_name ?? `#${lane.owner_id}`);
|
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||||
return (
|
return (
|
||||||
@@ -366,7 +370,7 @@ export function WorkspaceTree() {
|
|||||||
aria-hidden
|
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"
|
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>
|
||||||
<span className="truncate">{ownerLabel}</span>
|
<span className="truncate">{ownerLabel}</span>
|
||||||
<span className="ml-auto shrink-0 tabular-nums">
|
<span className="ml-auto shrink-0 tabular-nums">
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* @file PathPickButton.test.tsx
|
* @file PathPickButton.test.tsx
|
||||||
* @description Unit tests for the Browse button beside path fields: a chosen
|
* @description Unit tests for the Browse button beside path fields: a chosen
|
||||||
* path fills the field, a Cancel changes nothing, a folder falls back to the
|
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
||||||
* in-app browser on ANY native-picker failure, and a file falls back to a
|
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
||||||
* "type the path" hint only when no picker exists for this browser.
|
* EREMOTE included), where a double-click on a file picks it.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { PathPickButton } from "../PathPickButton";
|
import { PathPickButton } from "../PathPickButton";
|
||||||
|
|
||||||
vi.mock("../../lib/api", () => ({
|
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", () => ({
|
vi.mock("../../lib/toast", () => ({
|
||||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||||
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
|
|||||||
expect(toast.error).not.toHaveBeenCalled();
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
|
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
|
||||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
|
const onPick = vi.fn();
|
||||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
|
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
||||||
render(<PathPickButton kind="file" onPick={vi.fn()} />);
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
const button = screen.getByRole("button");
|
path: "/home/u/.ssh",
|
||||||
await userEvent.click(button);
|
parent: "/home/u",
|
||||||
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
|
entries: [
|
||||||
await userEvent.click(button);
|
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
|
||||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
|
],
|
||||||
|
});
|
||||||
|
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||||
|
await userEvent.click(screen.getByRole("button"));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
||||||
|
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
|
||||||
|
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||||
|
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
||||||
|
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(toast.info).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
|
|||||||
}) as unknown as Lane;
|
}) as unknown as Lane;
|
||||||
|
|
||||||
const lanes = [
|
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(2, null, null, "Old"),
|
||||||
lane(3, ME.id, "ana", "Mine"),
|
lane(3, ME.id, "ana", "Mine"),
|
||||||
lane(4, 1, "admin", "Admin site"),
|
lane(4, 1, "admin", "Admin site"),
|
||||||
@@ -113,6 +113,14 @@ describe("WorkspaceTree for an analyst", () => {
|
|||||||
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
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 () => {
|
it("folds a group and remembers it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderTree();
|
renderTree();
|
||||||
|
|||||||
@@ -154,7 +154,12 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
||||||
// Its OWN sessions only — never the rest of its folder.
|
// Its OWN sessions only — never the rest of its folder.
|
||||||
expect(api.sessions.list).toHaveBeenCalledTimes(1);
|
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({
|
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
||||||
"1": true,
|
"1": true,
|
||||||
});
|
});
|
||||||
@@ -175,11 +180,21 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
|
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
|
||||||
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
|
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
|
||||||
await user.click(screen.getByTestId("workspace-sessions-more-1"));
|
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");
|
await screen.findByText("Session 8");
|
||||||
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
||||||
await user.click(screen.getByTestId("workspace-sessions-less-1"));
|
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(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -197,6 +212,38 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
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 () => {
|
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderTree();
|
renderTree();
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
* the reader is there: anything that makes it taller — a new line, the live
|
* the reader is there: anything that makes it taller — a new line, the live
|
||||||
* step, an image loading, the pane shown again after being hidden by CSS
|
* step, an image loading, the pane shown again after being hidden by CSS
|
||||||
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
||||||
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
|
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
|
||||||
|
* chat passes in whether the CLI waits on the user (`waiting`, with
|
||||||
|
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||||
|
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
||||||
|
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -57,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 { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList, promptTokens } from "./MessageList";
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
|
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||||
|
import { throttle } from "../../lib/throttle";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||||
|
|
||||||
@@ -76,6 +82,10 @@ const POLL_INTERVAL_MS = 3000;
|
|||||||
// Rescan the transcripts list periodically so new subagents that spawn
|
// Rescan the transcripts list periodically so new subagents that spawn
|
||||||
// mid-session appear in the dropdown without a page reload.
|
// mid-session appear in the dropdown without a page reload.
|
||||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||||
|
// 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 {
|
interface ConversationViewProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -89,18 +99,47 @@ interface ConversationViewProps {
|
|||||||
live?: boolean;
|
live?: boolean;
|
||||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||||
follow?: number;
|
follow?: number;
|
||||||
|
/** The CLI is asking the user something (a permission prompt). */
|
||||||
|
waiting?: boolean;
|
||||||
|
/** Shows the pane's terminal, where that question is answered. */
|
||||||
|
onOpenTerminal?: () => void;
|
||||||
|
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
|
||||||
|
stopped?: number;
|
||||||
|
/** The newest reply's prompt size (input + cache) and model: how full the
|
||||||
|
* context is. */
|
||||||
|
onContext?: (tokens: number, model: string | null) => void;
|
||||||
|
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||||
|
onNotStarted?: (notStarted: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationView({
|
function ConversationViewPane({
|
||||||
sessionId,
|
sessionId,
|
||||||
initialTranscriptId,
|
initialTranscriptId,
|
||||||
fill = false,
|
fill = false,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
live = false,
|
live = false,
|
||||||
follow = 0,
|
follow = 0,
|
||||||
|
waiting = false,
|
||||||
|
onOpenTerminal,
|
||||||
|
stopped = 0,
|
||||||
|
onContext,
|
||||||
|
onNotStarted,
|
||||||
}: ConversationViewProps) {
|
}: ConversationViewProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
|
// A live pane's session has no row until its first hook — and Claude Code
|
||||||
|
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||||
|
// other question before the session starts) waits unseen in the terminal.
|
||||||
|
const [notStarted, setNotStarted] = useState(false);
|
||||||
|
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onContext) return;
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i]!;
|
||||||
|
if (m.type === "assistant" && m.usage)
|
||||||
|
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||||
|
}
|
||||||
|
}, [messages, onContext]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
@@ -128,6 +167,10 @@ export function ConversationView({
|
|||||||
// Bumped whenever the session or the selected transcript changes: a tail
|
// Bumped whenever the session or the selected transcript changes: a tail
|
||||||
// fetch started for the previous transcript must not append to the new one.
|
// fetch started for the previous transcript must not append to the new one.
|
||||||
const genRef = useRef(0);
|
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
|
// Refresh-button spinner state - separate from initial `loading` so the
|
||||||
// existing skeleton doesn't blink during a manual refresh.
|
// existing skeleton doesn't blink during a manual refresh.
|
||||||
|
|
||||||
@@ -146,7 +189,7 @@ export function ConversationView({
|
|||||||
}
|
}
|
||||||
loadTranscripts();
|
loadTranscripts();
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
if (document.visibilityState === "visible") loadTranscripts();
|
if (isShown(onScreen)) loadTranscripts();
|
||||||
}, TRANSCRIPTS_REFRESH_MS);
|
}, TRANSCRIPTS_REFRESH_MS);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -178,6 +221,7 @@ export function ConversationView({
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setNotStarted(false);
|
||||||
setMessages(result.messages);
|
setMessages(result.messages);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setHasMore(result.has_more);
|
setHasMore(result.has_more);
|
||||||
@@ -189,6 +233,7 @@ export function ConversationView({
|
|||||||
// to show yet, and the poll below picks it up once it exists.
|
// to show yet, and the poll below picks it up once it exists.
|
||||||
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
|
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
|
||||||
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||||
|
setNotStarted(notYet);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -228,6 +273,7 @@ export function ConversationView({
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
});
|
});
|
||||||
if (gen !== genRef.current) return; // another transcript is shown now
|
if (gen !== genRef.current) return; // another transcript is shown now
|
||||||
|
setNotStarted(false);
|
||||||
if (result.messages.length === 0) return;
|
if (result.messages.length === 0) return;
|
||||||
|
|
||||||
lastLineRef.current = result.last_line;
|
lastLineRef.current = result.last_line;
|
||||||
@@ -263,24 +309,33 @@ export function ConversationView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sessionId, selectedTranscript]);
|
}, [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
|
// Hook coverage isn't complete (a user-typed message fires no hook), so we
|
||||||
// also poll below to catch what WS misses.
|
// also poll below to catch what WS misses.
|
||||||
|
const fetchSoon = useMemo(
|
||||||
|
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||||
|
[fetchNewMessages]
|
||||||
|
);
|
||||||
|
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||||
// Hidden tab: the visibility handler below catches up once on show.
|
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||||
if (document.visibilityState !== "visible") return;
|
if (!isShown(onScreen)) return;
|
||||||
if (isRemoteDataRefreshMessage(msg)) {
|
if (isRemoteDataRefreshMessage(msg)) {
|
||||||
fetchNewMessages();
|
fetchSoon();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type !== "new_event") return;
|
if (msg.type !== "new_event") return;
|
||||||
const data = msg.data as { session_id?: string };
|
const data = msg.data as { session_id?: string };
|
||||||
if (data.session_id !== sessionId) return;
|
if (data.session_id !== sessionId) return;
|
||||||
fetchNewMessages();
|
fetchSoon();
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
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
|
// Resync on WebSocket reconnect: events that landed during a transient
|
||||||
// disconnect are gone from the bus, but the JSONL still has them, so a
|
// disconnect are gone from the bus, but the JSONL still has them, so a
|
||||||
@@ -301,7 +356,7 @@ export function ConversationView({
|
|||||||
function start() {
|
function start() {
|
||||||
if (interval !== null) return;
|
if (interval !== null) return;
|
||||||
interval = window.setInterval(() => {
|
interval = window.setInterval(() => {
|
||||||
if (document.visibilityState === "visible") fetchNewMessages();
|
if (isShown(onScreen)) fetchNewMessages();
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
function stop() {
|
function stop() {
|
||||||
@@ -327,7 +382,7 @@ export function ConversationView({
|
|||||||
stop();
|
stop();
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
};
|
};
|
||||||
}, [fetchNewMessages]);
|
}, [fetchNewMessages, onScreen]);
|
||||||
|
|
||||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||||
// a sync without reloading the page.
|
// a sync without reloading the page.
|
||||||
@@ -494,7 +549,9 @@ export function ConversationView({
|
|||||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||||
>
|
>
|
||||||
<div ref={contentRef}>
|
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||||
|
(the outline's file button floats there). */}
|
||||||
|
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
|
||||||
{/* History loading indicator */}
|
{/* History loading indicator */}
|
||||||
{loadingHistory && (
|
{loadingHistory && (
|
||||||
<div className="flex justify-center py-3">
|
<div className="flex justify-center py-3">
|
||||||
@@ -516,6 +573,22 @@ export function ConversationView({
|
|||||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||||
{t("conversation.loading")}
|
{t("conversation.loading")}
|
||||||
</div>
|
</div>
|
||||||
|
) : messages.length === 0 && notStarted ? (
|
||||||
|
<div className="mx-auto max-w-md py-12 text-center">
|
||||||
|
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||||
|
{t("conversation.notStartedBody")}
|
||||||
|
</p>
|
||||||
|
{onOpenTerminal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenTerminal}
|
||||||
|
className="btn-secondary mt-3 text-xs"
|
||||||
|
>
|
||||||
|
{t("conversation.openTerminal")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="mx-auto max-w-md py-12 text-center">
|
<div className="mx-auto max-w-md py-12 text-center">
|
||||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||||
@@ -524,7 +597,15 @@ export function ConversationView({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
|
<MessageList
|
||||||
|
messages={messages}
|
||||||
|
loading={false}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
live={live}
|
||||||
|
waiting={waiting}
|
||||||
|
onOpenTerminal={onOpenTerminal}
|
||||||
|
stopped={stopped}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -565,3 +646,7 @@ export function ConversationView({
|
|||||||
</div>
|
</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);
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
* through `OpenFileContext` when the page provides one; `linkify` does the
|
||||||
* same links for plain text.
|
* same links for plain text. To make a reply easy to scan, bold is coloured
|
||||||
|
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
||||||
|
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
||||||
|
* of how serious it is.
|
||||||
*
|
*
|
||||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||||
@@ -256,6 +259,22 @@ const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
|||||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
// 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]!);
|
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||||
|
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
||||||
|
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
|
||||||
|
// "2fd0c903" stay text.
|
||||||
|
const NUM_RE =
|
||||||
|
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
|
||||||
|
const numStart = (text: string, i: number) =>
|
||||||
|
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
|
||||||
|
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
|
||||||
|
const LABEL_RE =
|
||||||
|
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
|
||||||
|
const LABEL_CLASS = (word: string) =>
|
||||||
|
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
|
||||||
|
? "text-status-danger"
|
||||||
|
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
|
||||||
|
? "text-status-warning"
|
||||||
|
: "text-accent";
|
||||||
|
|
||||||
function UrlLink({ url }: { url: string }) {
|
function UrlLink({ url }: { url: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -354,7 +373,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||||||
if (boldM) {
|
if (boldM) {
|
||||||
push(
|
push(
|
||||||
<strong className="font-semibold text-fg-primary">
|
<strong className="font-bold text-accent">
|
||||||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||||||
</strong>
|
</strong>
|
||||||
);
|
);
|
||||||
@@ -419,6 +438,24 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
|
||||||
|
if (labelM) {
|
||||||
|
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
|
||||||
|
i += labelM[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
|
||||||
|
if (numM) {
|
||||||
|
push(
|
||||||
|
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
|
||||||
|
{numM[0]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
i += numM[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
buf += text[i]!;
|
buf += text[i]!;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,27 @@
|
|||||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||||
* the Conversation tab on the Session detail page. An image the prompt box
|
* the Conversation tab on the Session detail page. An image the prompt box
|
||||||
* attached reaches the transcript as its saved path; a user turn shows the
|
* attached reaches the transcript as its saved path; a user turn shows the
|
||||||
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
* image instead (`GET /api/run/drop/:name`) — any other attached file as a
|
||||||
|
* chip that opens its preview through `onOpenFile` —, and Claude Code's own
|
||||||
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
||||||
* a System row; URLs and absolute paths in a turn are links (a path opens
|
* a System row; URLs and absolute paths in a turn are links (a path opens
|
||||||
* through `onOpenFile`). Each assistant turn that changed
|
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
|
||||||
|
* like the prompt box's (`lib/promptTokens.ts`). Each assistant turn that changed
|
||||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||||
* when the caller can (`onOpenFile`), and a row (or the card's header, for
|
* when the caller can (`onOpenFile`), and a row (or the card's header, for
|
||||||
* every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is
|
* every file in it) drags into a prompt box as an `@path` attachment. An
|
||||||
|
* AskUserQuestion is its own card (`AskCard`), not part of the collapsed work
|
||||||
|
* line: each question with the answer the user picked or typed. A thinking block that carries text is
|
||||||
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
||||||
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
||||||
* chat (`live`) ends, until its turn is over, with what the model is doing
|
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||||
* now — the call still running, else "Thinking…" — and for how long.
|
* now — the calls still running (several when it runs them in parallel),
|
||||||
|
* else "Thinking…", or the question the CLI waits on with a way to its
|
||||||
|
* terminal (`waiting`) — and, like the terminal's status line, how long the
|
||||||
|
* turn has run and the output tokens it has made; a call running long shows
|
||||||
|
* its own time, and a line silent for minutes turns amber. A turn that ended
|
||||||
|
* closes with how long it took and its output.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -66,7 +75,7 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { memo, useEffect, useState, useMemo } from "react";
|
import { Fragment, memo, useContext, useEffect, useRef, useState, useMemo } from "react";
|
||||||
import { ImageLightbox } from "../ImageLightbox";
|
import { ImageLightbox } from "../ImageLightbox";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
@@ -85,6 +94,11 @@ import {
|
|||||||
Cog,
|
Cog,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Check,
|
||||||
|
Hand,
|
||||||
|
SquareTerminal,
|
||||||
|
MessageCircleQuestion,
|
||||||
|
CornerDownRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||||
|
|
||||||
@@ -130,6 +144,7 @@ const SENDER_STYLES: Record<
|
|||||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||||
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||||
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
||||||
|
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
|
||||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||||
|
|
||||||
@@ -140,6 +155,14 @@ interface MessageListProps {
|
|||||||
onOpenFile?: (path: string) => void;
|
onOpenFile?: (path: string) => void;
|
||||||
/** The session is running in a pane: show the step in progress. */
|
/** The session is running in a pane: show the step in progress. */
|
||||||
live?: boolean;
|
live?: boolean;
|
||||||
|
/** The CLI is asking the user something (a permission prompt): the live
|
||||||
|
* line says so instead of "Thinking…". */
|
||||||
|
waiting?: boolean;
|
||||||
|
/** Shows the pane's terminal, where that question is answered. */
|
||||||
|
onOpenTerminal?: () => void;
|
||||||
|
/** Bumped by the chat's Stop: the turn on screen is over, even when the
|
||||||
|
* transcript never says so. */
|
||||||
|
stopped?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a map from tool_use id → tool_result for matching */
|
/** Build a map from tool_use id → tool_result for matching */
|
||||||
@@ -156,9 +179,20 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
|
|||||||
return map;
|
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
|
/** 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. */
|
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
|
||||||
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||||
return (
|
return (
|
||||||
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
|
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
|
||||||
);
|
);
|
||||||
@@ -205,12 +239,32 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact pill for /command invocations parsed out of TUI markup. */
|
/** Typed text with its `/command` and `@path` tokens as chips, the rest linkified. */
|
||||||
|
function typedText(text: string): React.ReactNode[] {
|
||||||
|
return splitPrompt(text).map((tok, i) =>
|
||||||
|
tok.kind === "text" ? (
|
||||||
|
<Fragment key={i}>{linkify(tok.text)}</Fragment>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`rounded-md px-1.5 py-0.5 font-mono text-[0.92em] font-medium ${TOKEN_TONE[tok.kind]}`}
|
||||||
|
>
|
||||||
|
{tok.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact pill for /command invocations parsed out of TUI markup: the
|
||||||
|
* command in the prompt box's colour, its arguments after it. */
|
||||||
function CommandPill({ display }: { display: string }) {
|
function CommandPill({ display }: { display: string }) {
|
||||||
|
const [name, ...args] = display.split(" ");
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
|
<div className="inline-flex max-w-full items-baseline gap-2 rounded-lg border border-accent/25 bg-accent/10 px-2.5 py-1 font-mono text-sm">
|
||||||
<span className="text-status-success/70">›</span>
|
<span className="font-semibold text-accent">{name}</span>
|
||||||
<span className="break-all">{display}</span>
|
{args.length > 0 && (
|
||||||
|
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -422,7 +476,9 @@ type Item =
|
|||||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||||
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
||||||
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
|
| { kind: "ask"; key: string; block: TranscriptContent }
|
||||||
|
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
|
||||||
|
| { kind: "done"; key: string; ms: number; output: number };
|
||||||
|
|
||||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||||
|
|
||||||
@@ -431,9 +487,20 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
|||||||
// The file-changing calls of the turn so far, closed into one card when the
|
// The file-changing calls of the turn so far, closed into one card when the
|
||||||
// human speaks again (or the list ends).
|
// human speaks again (or the list ends).
|
||||||
let edits: TranscriptContent[] = [];
|
let edits: TranscriptContent[] = [];
|
||||||
|
// The turn's prompt time, its newest assistant line and its output so far,
|
||||||
|
// for the "Done in …" line under a turn that ended.
|
||||||
|
let start: string | null = null;
|
||||||
|
let reply: TranscriptMessage | null = null;
|
||||||
|
let output = outputCounter();
|
||||||
const endTurn = (key: string) => {
|
const endTurn = (key: string) => {
|
||||||
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
|
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
|
||||||
edits = [];
|
edits = [];
|
||||||
|
if (start && reply?.timestamp && reply.stop_reason === "end_turn") {
|
||||||
|
const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime();
|
||||||
|
items.push({ kind: "done", key: `done:${key}`, ms, output: output.total });
|
||||||
|
}
|
||||||
|
reply = null;
|
||||||
|
output = outputCounter();
|
||||||
};
|
};
|
||||||
messages.forEach((msg, idx) => {
|
messages.forEach((msg, idx) => {
|
||||||
const key = keys[idx]!;
|
const key = keys[idx]!;
|
||||||
@@ -443,6 +510,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
|||||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||||
if (sender === "user") {
|
if (sender === "user") {
|
||||||
endTurn(key);
|
endTurn(key);
|
||||||
|
start = msg.timestamp ?? null;
|
||||||
return items.push({ kind: "user", key, msg });
|
return items.push({ kind: "user", key, msg });
|
||||||
}
|
}
|
||||||
if (sender !== "assistant") {
|
if (sender !== "assistant") {
|
||||||
@@ -465,10 +533,15 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
|||||||
}
|
}
|
||||||
return items.push({ kind: "row", key, msg });
|
return items.push({ kind: "row", key, msg });
|
||||||
}
|
}
|
||||||
|
reply = msg;
|
||||||
|
if (msg.usage) output.add(msg.usage);
|
||||||
msg.content.forEach((block, b) => {
|
msg.content.forEach((block, b) => {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||||
if (block.type === "tool_use") {
|
// A question to the user is a turn of the conversation, not hidden work.
|
||||||
|
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||||
|
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
||||||
|
} else if (block.type === "tool_use") {
|
||||||
if (last?.kind === "work") last.blocks.push(block);
|
if (last?.kind === "work") last.blocks.push(block);
|
||||||
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||||
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
||||||
@@ -482,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
|
/** 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. */
|
* the lines added and removed. A call still running or failed is left out. */
|
||||||
function EditedFiles({
|
function EditedFilesCard({
|
||||||
blocks,
|
blocks,
|
||||||
toolResultMap,
|
toolResultMap,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
@@ -552,15 +625,39 @@ function EditedFiles({
|
|||||||
</div>
|
</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({ messages, loading, onOpenFile, live = false }: MessageListProps) {
|
function MessageListView({
|
||||||
|
messages,
|
||||||
|
loading,
|
||||||
|
onOpenFile,
|
||||||
|
live = false,
|
||||||
|
waiting = false,
|
||||||
|
onOpenTerminal,
|
||||||
|
stopped = 0,
|
||||||
|
}: MessageListProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||||
const pending = useMemo(
|
|
||||||
() => (live ? workingOn(messages, toolResultMap) : null),
|
|
||||||
[live, messages, toolResultMap]
|
|
||||||
);
|
|
||||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||||
|
const lastKey = keys[keys.length - 1] ?? null;
|
||||||
|
// The newest line when Stop was pressed. Esc before the model wrote anything
|
||||||
|
// puts the prompt back in Claude Code's input box and writes no
|
||||||
|
// "[Request interrupted…]" line (issue #74), so the transcript still ends on
|
||||||
|
// the prompt: no live line until a newer one arrives.
|
||||||
|
const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null });
|
||||||
|
if (stop.n !== stopped) setStop({ n: stopped, on: lastKey });
|
||||||
|
const halted = stop.on !== null && stop.on === lastKey;
|
||||||
|
const pending = useMemo(
|
||||||
|
() => (live && !halted ? workingOn(messages, toolResultMap) : null),
|
||||||
|
[live, halted, messages, toolResultMap]
|
||||||
|
);
|
||||||
|
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
|
||||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -598,6 +695,14 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
|||||||
return (
|
return (
|
||||||
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
||||||
);
|
);
|
||||||
|
case "ask":
|
||||||
|
return (
|
||||||
|
<AskCard
|
||||||
|
key={item.key}
|
||||||
|
block={item.block}
|
||||||
|
result={item.block.id ? (toolResultMap.get(item.block.id) ?? null) : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "edits":
|
case "edits":
|
||||||
// The card closes a finished turn; one still working has none yet.
|
// The card closes a finished turn; one still working has none yet.
|
||||||
if (pending && item === items[items.length - 1]) return null;
|
if (pending && item === items[items.length - 1]) return null;
|
||||||
@@ -609,9 +714,19 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
|||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "done":
|
||||||
|
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
{pending && (
|
||||||
|
<RunningStep
|
||||||
|
{...pending}
|
||||||
|
start={stats?.start ?? null}
|
||||||
|
tokens={stats?.output ?? 0}
|
||||||
|
waiting={waiting}
|
||||||
|
onOpenTerminal={onOpenTerminal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</OpenFileContext.Provider>
|
</OpenFileContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -621,88 +736,349 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
|||||||
// written (a crash, a kill) — stop saying it works.
|
// written (a crash, a kill) — stop saying it works.
|
||||||
const IDLE_AFTER_MS = 15 * 60_000;
|
const IDLE_AFTER_MS = 15 * 60_000;
|
||||||
|
|
||||||
/** What the model is doing while its turn is not over: the call still
|
/** What the model is doing while its turn is not over: the calls of the turn
|
||||||
* waiting for its result (`block`), else thinking (`block: null`); `since`
|
* still waiting for their results (`blocks`, oldest first — parallel calls
|
||||||
* is that line's time. Null once the turn ended. */
|
* finish in any order), `since` the oldest one's time, `last` the newest
|
||||||
|
* line's; `blocks` is empty while it thinks. Null once the turn ended. */
|
||||||
export function workingOn(
|
export function workingOn(
|
||||||
messages: TranscriptMessage[],
|
messages: TranscriptMessage[],
|
||||||
results: Map<string, TranscriptContent>,
|
results: Map<string, TranscriptContent>,
|
||||||
now = Date.now()
|
now = Date.now()
|
||||||
): { block: TranscriptContent | null; since: string | null } | null {
|
): { blocks: TranscriptContent[]; since: string | null; last: string | null } | null {
|
||||||
|
const blocks: TranscriptContent[] = [];
|
||||||
|
let since: string | null = null;
|
||||||
|
let last: string | null = null;
|
||||||
|
// The newest line's verdict: undefined until it is found.
|
||||||
|
let alive: boolean | undefined;
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const msg = messages[i]!;
|
const msg = messages[i]!;
|
||||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||||
// A rename marker or a harness line says nothing about the turn.
|
// A rename marker or a harness line says nothing about the turn.
|
||||||
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
|
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
|
||||||
const since = msg.timestamp ?? null;
|
const at = msg.timestamp ?? null;
|
||||||
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
|
const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS;
|
||||||
|
const newest = alive === undefined;
|
||||||
|
if (newest) last = at;
|
||||||
if (msg.type === "assistant") {
|
if (msg.type === "assistant") {
|
||||||
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
|
// "end_turn": over, whatever the transcript failed to pair.
|
||||||
if (call?.id && !results.has(call.id)) return { block: call, since };
|
if (newest && msg.stop_reason === "end_turn") return null;
|
||||||
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
|
for (const b of [...msg.content].reverse()) {
|
||||||
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
|
if (b.type === "tool_use" && b.id && !results.has(b.id)) {
|
||||||
|
blocks.unshift(b);
|
||||||
|
since = at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "tool_use": a call follows. None (an older server): over.
|
||||||
|
if (newest) alive = msg.stop_reason === "tool_use" && fresh;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
// A prompt or a tool result: the model answers it next — unless the user stopped it.
|
// A prompt or a tool result: the model answers it next — unless the user stopped it.
|
||||||
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
|
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
|
||||||
if (text.startsWith("[Request interrupted")) return null;
|
if (text.startsWith("[Request interrupted")) return null;
|
||||||
return fresh ? { block: null, since } : null;
|
if (newest) alive = fresh;
|
||||||
|
// The prompt that opened the turn: nothing before it is this turn's.
|
||||||
|
if (text) break;
|
||||||
}
|
}
|
||||||
return null;
|
if (blocks.length) return { blocks, since, last };
|
||||||
|
return alive ? { blocks, since: null, last } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Claude Desktop's live line: the step running now and for how long. */
|
/** Output tokens counted once per API call: Claude Code writes one line per
|
||||||
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
|
* content block, each repeating its call's usage — consecutive lines with
|
||||||
|
* the same prompt size are one call. */
|
||||||
|
function outputCounter() {
|
||||||
|
let total = 0;
|
||||||
|
let call: string | null = null;
|
||||||
|
let callOutput = 0;
|
||||||
|
return {
|
||||||
|
add(u: NonNullable<TranscriptMessage["usage"]>) {
|
||||||
|
const key = `${u.input_tokens}:${u.cache_read_input_tokens ?? 0}:${u.cache_creation_input_tokens ?? 0}`;
|
||||||
|
if (key !== call) {
|
||||||
|
total += callOutput;
|
||||||
|
call = key;
|
||||||
|
callOutput = 0;
|
||||||
|
}
|
||||||
|
callOutput = Math.max(callOutput, u.output_tokens || 0);
|
||||||
|
},
|
||||||
|
get total() {
|
||||||
|
return total + callOutput;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live turn so far, as the terminal counts it: `start` is the prompt
|
||||||
|
* that opened it, `output` the output tokens of every API call since. */
|
||||||
|
export function turnStats(messages: TranscriptMessage[]): { start: string | null; output: number } {
|
||||||
|
const output = outputCounter();
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i]!;
|
||||||
|
if (msg.type === "assistant") {
|
||||||
|
if (msg.usage) output.add(msg.usage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sender = msg.sender ?? "user";
|
||||||
|
if (sender === "user" && msg.content.some((c) => c.type === "text")) {
|
||||||
|
return { start: msg.timestamp ?? null, output: output.total };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: null, output: output.total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A call running this long shows its own time beside the turn's.
|
||||||
|
const STEP_SHOWN_MS = 30_000;
|
||||||
|
// No new line for this long: the line turns amber — the step may be stuck.
|
||||||
|
const QUIET_MS = 3 * 60_000;
|
||||||
|
|
||||||
|
/** A number that counts up to its new value instead of jumping (none when
|
||||||
|
* the user asked for reduced motion). */
|
||||||
|
function useCountUp(value: number): number {
|
||||||
|
const [shown, setShown] = useState(value);
|
||||||
|
const shownRef = useRef(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const from = shownRef.current;
|
||||||
|
if (from === value) return;
|
||||||
|
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reduce || typeof requestAnimationFrame === "undefined") {
|
||||||
|
shownRef.current = value;
|
||||||
|
setShown(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t0 = performance.now();
|
||||||
|
let raf = 0;
|
||||||
|
const step = (t: number) => {
|
||||||
|
const k = Math.min(1, (t - t0) / 600);
|
||||||
|
shownRef.current = Math.round(from + (value - from) * k);
|
||||||
|
setShown(shownRef.current);
|
||||||
|
if (k < 1) raf = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(step);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [value]);
|
||||||
|
return shown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A call's one-line title: its description (Claude Code's Bash
|
||||||
|
* `description`), else the tool and its target. */
|
||||||
|
function stepTitle(block: TranscriptContent): string {
|
||||||
|
const input = block.input as Record<string, unknown> | undefined;
|
||||||
|
return typeof input?.description === "string" && input.description
|
||||||
|
? input.description
|
||||||
|
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The terminal's live line: the step running now (or the question the CLI
|
||||||
|
* waits on), then the turn's time and output tokens so far. */
|
||||||
|
function RunningStep({
|
||||||
|
blocks,
|
||||||
|
since,
|
||||||
|
last,
|
||||||
|
start,
|
||||||
|
tokens,
|
||||||
|
waiting,
|
||||||
|
onOpenTerminal,
|
||||||
|
}: {
|
||||||
|
blocks: TranscriptContent[];
|
||||||
|
since: string | null;
|
||||||
|
last: string | null;
|
||||||
|
start: string | null;
|
||||||
|
tokens: number;
|
||||||
|
waiting: boolean;
|
||||||
|
onOpenTerminal?: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
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);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
const input = block?.input as Record<string, unknown> | undefined;
|
const counted = useCountUp(tokens);
|
||||||
const title = !block
|
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
|
||||||
? t("conversation.working")
|
const first = blocks[0];
|
||||||
: typeof input?.description === "string" && input.description
|
const step = first ? age(since) : 0;
|
||||||
? input.description
|
const quiet = !waiting && age(last) > QUIET_MS;
|
||||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
const title = waiting
|
||||||
|
? first
|
||||||
|
? t("conversation.waitingFor", { step: stepTitle(first) })
|
||||||
|
: t("conversation.waiting")
|
||||||
|
: first
|
||||||
|
? stepTitle(first)
|
||||||
|
: t("conversation.working");
|
||||||
|
const extra = [
|
||||||
|
start && formatMs(age(start)),
|
||||||
|
tokens > 0 && t("conversation.turnTokens", { tokens: fmt(counted) }),
|
||||||
|
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
|
||||||
|
].filter(Boolean);
|
||||||
|
const amber = waiting || quiet;
|
||||||
return (
|
return (
|
||||||
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
|
<div ref={boxRef} data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
|
||||||
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="min-w-0 truncate">{title}</span>
|
{waiting ? (
|
||||||
{since && (
|
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
|
||||||
<span className="flex-shrink-0 font-mono text-xs">
|
) : (
|
||||||
{formatMs(now - parseDate(since).getTime())}
|
<Loader2
|
||||||
|
className={`h-3.5 w-3.5 flex-shrink-0 animate-spin ${quiet ? "text-status-warning" : "text-accent"}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className={`min-w-0 truncate ${amber ? "text-status-warning" : "text-shimmer"}`}>
|
||||||
|
{title}
|
||||||
</span>
|
</span>
|
||||||
|
{blocks.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll((v) => !v)}
|
||||||
|
aria-expanded={showAll}
|
||||||
|
className="flex-shrink-0 rounded-full border border-border px-1.5 text-xs hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t("conversation.moreSteps", { count: blocks.length - 1 })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step > STEP_SHOWN_MS && (
|
||||||
|
<span
|
||||||
|
className={`flex-shrink-0 font-mono text-xs ${step > QUIET_MS ? "text-status-warning" : ""}`}
|
||||||
|
>
|
||||||
|
{formatMs(step)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{extra.length > 0 && (
|
||||||
|
<span className="flex-shrink-0 font-mono text-xs">{`(${extra.join(" · ")})`}</span>
|
||||||
|
)}
|
||||||
|
{waiting && onOpenTerminal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="chat-open-terminal"
|
||||||
|
onClick={onOpenTerminal}
|
||||||
|
className="flex flex-shrink-0 items-center gap-1 rounded-md border border-status-warning/40 px-2 py-0.5 text-xs text-status-warning hover:bg-status-warning/10"
|
||||||
|
>
|
||||||
|
<SquareTerminal className="h-3.5 w-3.5" />
|
||||||
|
{t("conversation.openTerminal")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showAll && blocks.length > 1 && (
|
||||||
|
<ul className="ml-5 space-y-0.5 text-xs">
|
||||||
|
{blocks.map((b, i) => (
|
||||||
|
<li key={b.id ?? i} className="truncate">
|
||||||
|
{stepTitle(b)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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`, minus the " (Recommended)" marker),
|
||||||
|
* "declined" when they refused it, no answer yet while the CLI still asks. */
|
||||||
|
function AskCard({
|
||||||
|
block,
|
||||||
|
result,
|
||||||
|
}: {
|
||||||
|
block: TranscriptContent;
|
||||||
|
result: TranscriptContent | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const input = block.input as { questions?: unknown } | undefined;
|
||||||
|
const questions = (Array.isArray(input?.questions) ? input.questions : [])
|
||||||
|
.map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null))
|
||||||
|
.filter((q): q is string => typeof q === "string");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="chat-ask"
|
||||||
|
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{questions.map((q, i) => {
|
||||||
|
const answer = stripRecommended(result?.answers?.[q]);
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex items-start gap-1.5 text-fg-secondary">
|
||||||
|
<MessageCircleQuestion className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>{q}</span>
|
||||||
|
</div>
|
||||||
|
{answer && (
|
||||||
|
<div className="ml-5 mt-0.5 flex items-start gap-1.5 font-medium text-fg-primary">
|
||||||
|
<CornerDownRight className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
||||||
|
<span className="whitespace-pre-wrap">{answer}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{result?.is_error && (
|
||||||
|
<div className="text-xs text-fg-muted">{t("conversation.askDeclined")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TurnDone({ ms, output }: { ms: number; output: number }) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
return (
|
||||||
|
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||||
|
<Check className="h-3 w-3 flex-shrink-0" />
|
||||||
|
{[
|
||||||
|
t("conversation.turnDone", { time: formatMs(ms) }),
|
||||||
|
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const meta = "text-[10px] font-mono text-fg-muted";
|
const meta = "text-[10px] font-mono text-fg-muted";
|
||||||
|
|
||||||
// What the prompt box sends for an attached image: its saved path under the
|
// What the prompt box sends for an attached file: its saved path under the
|
||||||
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
|
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
|
||||||
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||||
const DROP_IMAGE =
|
const DROP_PATH =
|
||||||
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
|
||||||
|
const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp)$/i;
|
||||||
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
|
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
|
||||||
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
|
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
|
||||||
|
|
||||||
/** A user turn's text without its dropped-image paths (and Claude Code's
|
/** A user turn's text without its dropped-file paths (and Claude Code's
|
||||||
* `[Image #N]` placeholders for them), and those images' names. */
|
* `[Image #N]` placeholders for them), the images' names and the other
|
||||||
export function splitDropImages(text: string): { text: string; images: string[] } {
|
* files' full paths. */
|
||||||
|
export function splitDropImages(text: string): {
|
||||||
|
text: string;
|
||||||
|
images: string[];
|
||||||
|
files: string[];
|
||||||
|
} {
|
||||||
const images: string[] = [];
|
const images: string[] = [];
|
||||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
const files: string[] = [];
|
||||||
images.push(name);
|
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
|
||||||
|
if (IMAGE_EXT.test(name)) images.push(name);
|
||||||
|
else files.push(full);
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
text: images.length
|
text:
|
||||||
? rest
|
images.length || files.length
|
||||||
.replace(/\[Image #\d+\]/g, "")
|
? rest
|
||||||
.replace(/[ \t]+$/gm, "")
|
.replace(/\[Image #\d+\]/g, "")
|
||||||
.trim()
|
.replace(/[ \t]+$/gm, "")
|
||||||
: text,
|
.trim()
|
||||||
|
: text,
|
||||||
images,
|
images,
|
||||||
|
files,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,6 +1089,30 @@ function imageSourcePaths(msg: TranscriptMessage): string[] | null {
|
|||||||
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
|
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A dropped file other than an image: a chip that opens its preview when the
|
||||||
|
* page can (`OpenFileContext`). */
|
||||||
|
function DropFile({ path }: { path: string }) {
|
||||||
|
const open = useContext(OpenFileContext);
|
||||||
|
const name = path
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop()!
|
||||||
|
.replace(/^[0-9a-f]{16}-?/i, "");
|
||||||
|
const ext = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toUpperCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!open}
|
||||||
|
onClick={() => open?.(path)}
|
||||||
|
title={path}
|
||||||
|
className="flex max-w-64 items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2 text-left text-xs enabled:hover:border-accent/50"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<span className="truncate text-fg-primary">{name}</span>
|
||||||
|
{ext && <span className="shrink-0 font-mono text-[10px] text-fg-muted">{ext}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DropImage({ name }: { name: string }) {
|
function DropImage({ name }: { name: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [gone, setGone] = useState(false);
|
const [gone, setGone] = useState(false);
|
||||||
@@ -760,6 +1160,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
|||||||
[msg.content]
|
[msg.content]
|
||||||
);
|
);
|
||||||
const images = blocks.flatMap((b) => b?.images ?? []);
|
const images = blocks.flatMap((b) => b?.images ?? []);
|
||||||
|
const files = blocks.flatMap((b) => b?.files ?? []);
|
||||||
const hasText = blocks.some((b) => b?.text);
|
const hasText = blocks.some((b) => b?.text);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -767,7 +1168,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
|||||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||||
className="group flex flex-col items-end gap-1"
|
className="group flex flex-col items-end gap-1"
|
||||||
>
|
>
|
||||||
{images.length > 0 && (
|
{images.length + files.length > 0 && (
|
||||||
<div
|
<div
|
||||||
data-testid="chat-user-images"
|
data-testid="chat-user-images"
|
||||||
className="flex max-w-[85%] flex-wrap justify-end gap-2"
|
className="flex max-w-[85%] flex-wrap justify-end gap-2"
|
||||||
@@ -775,6 +1176,9 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
|||||||
{images.map((name) => (
|
{images.map((name) => (
|
||||||
<DropImage key={name} name={name} />
|
<DropImage key={name} name={name} />
|
||||||
))}
|
))}
|
||||||
|
{files.map((path) => (
|
||||||
|
<DropFile key={path} path={path} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasText && (
|
{hasText && (
|
||||||
@@ -792,7 +1196,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
|||||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||||
return plain ? (
|
return plain ? (
|
||||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{linkify(stripAnsi(text).trim())}
|
{typedText(stripAnsi(text).trim())}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
renderTextBlock(text, b)
|
renderTextBlock(text, b)
|
||||||
@@ -991,7 +1395,7 @@ function WorkStep({
|
|||||||
|
|
||||||
/** What the assistant did between two replies as one line, opening onto
|
/** What the assistant did between two replies as one line, opening onto
|
||||||
* one framed list of its steps. */
|
* one framed list of its steps. */
|
||||||
const WorkGroup = memo(function WorkGroup({
|
function WorkGroupLine({
|
||||||
blocks,
|
blocks,
|
||||||
toolResultMap,
|
toolResultMap,
|
||||||
}: {
|
}: {
|
||||||
@@ -1013,11 +1417,16 @@ const WorkGroup = memo(function WorkGroup({
|
|||||||
</Disclosure>
|
</Disclosure>
|
||||||
</div>
|
</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
|
/** 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. */
|
* new rows; its thinking-block expansion is its own state, kept by key. */
|
||||||
const MessageRow = memo(function MessageRow({
|
function MessageRowView({
|
||||||
msg,
|
msg,
|
||||||
toolResultMap,
|
toolResultMap,
|
||||||
}: {
|
}: {
|
||||||
@@ -1136,4 +1545,11 @@ const MessageRow = memo(function MessageRow({
|
|||||||
</div>
|
</div>
|
||||||
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file ConversationView.notYet.test.tsx
|
* @file ConversationView.notYet.test.tsx
|
||||||
* @description A live pane's new session has no row until its first hook:
|
* @description A live pane's new session has no row until its first hook:
|
||||||
* its 404 shows an empty chat, not "Session not found"; a replay still shows it.
|
* its 404 shows no "Session not found" (a replay still shows it) but says the
|
||||||
|
* session has not started — the CLI may be asking in the terminal, e.g. to
|
||||||
|
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { ConversationView } from "../ConversationView";
|
import { ConversationView } from "../ConversationView";
|
||||||
|
|
||||||
@@ -34,4 +36,23 @@ describe("ConversationView before a session's first hook", () => {
|
|||||||
render(<ConversationView sessionId="gone" fill />);
|
render(<ConversationView sessionId="gone" fill />);
|
||||||
expect(await screen.findByText("Session not found")).toBeTruthy();
|
expect(await screen.findByText("Session not found")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
|
||||||
|
const onOpenTerminal = vi.fn();
|
||||||
|
const onNotStarted = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConversationView
|
||||||
|
sessionId="new"
|
||||||
|
fill
|
||||||
|
live
|
||||||
|
onOpenTerminal={onOpenTerminal}
|
||||||
|
onNotStarted={onNotStarted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("No conversation records found.")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByText("Open terminal"));
|
||||||
|
expect(onOpenTerminal).toHaveBeenCalled();
|
||||||
|
expect(onNotStarted).toHaveBeenLastCalledWith(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,6 +66,43 @@ describe("<MarkdownContent />", () => {
|
|||||||
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
|
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownContent
|
||||||
|
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||||
|
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights an ISO date-time as one number", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownContent
|
||||||
|
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||||
|
expect(nums).toEqual([
|
||||||
|
"2026-10-12T08:00",
|
||||||
|
"2026-10-12T08:00:00Z",
|
||||||
|
"2026-10-12T08:00:00.123+07:00",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight numbers inside inline code", () => {
|
||||||
|
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
|
||||||
|
expect(container.querySelector("span.tabular-nums")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("colours a flag label by how serious it is", () => {
|
||||||
|
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
|
||||||
|
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
|
||||||
|
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
|
||||||
|
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
|
||||||
|
expect(screen.queryByText("Note")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("handles empty input safely", () => {
|
it("handles empty input safely", () => {
|
||||||
const { container } = render(<MarkdownContent text="" />);
|
const { container } = render(<MarkdownContent text="" />);
|
||||||
// Wrapper exists but no block elements
|
// Wrapper exists but no block elements
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @file MessageList.ask.test.tsx
|
||||||
|
* @description An AskUserQuestion call is not folded into the collapsed work
|
||||||
|
* line: the chat shows it as its own card — each question with the answer the
|
||||||
|
* user picked or typed (without the option's " (Recommended)" marker), "declined" when they refused it, nothing yet while the
|
||||||
|
* CLI still waits.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { MessageList } from "../MessageList";
|
||||||
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
|
const at = "2026-10-13T08:00:00.000Z";
|
||||||
|
const user = (text: string): TranscriptMessage =>
|
||||||
|
({
|
||||||
|
type: "user",
|
||||||
|
sender: "user",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
}) as TranscriptMessage;
|
||||||
|
const ask = (id: string, questions: string[]): TranscriptMessage =>
|
||||||
|
({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } },
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id,
|
||||||
|
name: "AskUserQuestion",
|
||||||
|
input: {
|
||||||
|
questions: questions.map((question) => ({
|
||||||
|
question,
|
||||||
|
header: "Pick",
|
||||||
|
options: [{ label: "Yes" }, { label: "No" }],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) as TranscriptMessage;
|
||||||
|
const result = (id: string, extra: Record<string, unknown>) =>
|
||||||
|
({
|
||||||
|
type: "user",
|
||||||
|
sender: "tool",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "tool_result", id, output: "x", ...extra }],
|
||||||
|
}) as TranscriptMessage;
|
||||||
|
|
||||||
|
describe("an AskUserQuestion card", () => {
|
||||||
|
it("shows each question with the user's answer, outside the work line", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[
|
||||||
|
user("go"),
|
||||||
|
ask("q1", ["Commit?", "Push?"]),
|
||||||
|
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const card = screen.getByTestId("chat-ask");
|
||||||
|
expect(within(card).getByText("Commit?")).toBeTruthy();
|
||||||
|
expect(within(card).getByText("Yes")).toBeTruthy();
|
||||||
|
expect(within(card).getByText("only after review")).toBeTruthy();
|
||||||
|
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the (Recommended) marker from a picked answer", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[
|
||||||
|
user("go"),
|
||||||
|
ask("q1", ["Commit?", "Tools?"]),
|
||||||
|
result("q1", {
|
||||||
|
answers: { "Commit?": "Yes (Recommended)", "Tools?": "Lint (Recommended), Test" },
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const card = screen.getByTestId("chat-ask");
|
||||||
|
expect(within(card).getByText("Yes")).toBeTruthy();
|
||||||
|
expect(within(card).getByText("Lint, Test")).toBeTruthy();
|
||||||
|
expect(within(card).queryByText(/Recommended/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says declined when refused, and shows no answer while still asked", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
|
||||||
|
);
|
||||||
|
const card = screen.getByTestId("chat-ask");
|
||||||
|
expect(within(card).getByText("Commit?")).toBeTruthy();
|
||||||
|
expect(within(card).queryByText(/declined/i)).toBeNull();
|
||||||
|
rerender(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
* @file MessageList.images.test.tsx
|
* @file MessageList.images.test.tsx
|
||||||
* @description An image the prompt box attached reaches the transcript as its
|
* @description An image the prompt box attached reaches the transcript as its
|
||||||
* saved drop path; the user's turn shows the image (served by
|
* saved drop path; the user's turn shows the image (served by
|
||||||
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
* `GET /api/run/drop/:name`), opens it full size on the page; any other
|
||||||
* other path as typed. Claude Code turns a pasted drop path into an image
|
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
|
||||||
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
||||||
* URLs and absolute paths in a turn are links.
|
* URLs and absolute paths in a turn are links.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -19,7 +19,7 @@ describe("dropped images in a user turn", () => {
|
|||||||
it("splitDropImages takes drop-dir image paths out of the text", () => {
|
it("splitDropImages takes drop-dir image paths out of the text", () => {
|
||||||
expect(
|
expect(
|
||||||
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
|
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
|
||||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
|
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
|
||||||
// Windows' temp dir, no prefix name.
|
// Windows' temp dir, no prefix name.
|
||||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||||
"0123456789abcdef.webp",
|
"0123456789abcdef.webp",
|
||||||
@@ -28,16 +28,36 @@ describe("dropped images in a user turn", () => {
|
|||||||
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
||||||
text: "fix this",
|
text: "fix this",
|
||||||
images: [SHOT],
|
images: [SHOT],
|
||||||
|
files: [],
|
||||||
});
|
});
|
||||||
// Not a drop, or not an image: left as typed.
|
// Any other dropped file comes out by its full path.
|
||||||
for (const text of [
|
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
|
||||||
"/home/me/shot.png",
|
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
|
||||||
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
|
// Not a drop: left as typed.
|
||||||
]) {
|
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
|
||||||
expect(splitDropImages(text)).toEqual({ text, images: [] });
|
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a dropped file as a chip that opens its preview", () => {
|
||||||
|
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
|
||||||
|
const opened: string[] = [];
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sender: "user",
|
||||||
|
timestamp: "2026-10-09T08:00:00.000Z",
|
||||||
|
content: [{ type: "text", text: `summarise ${pdf}` }],
|
||||||
|
},
|
||||||
|
] as TranscriptMessage[];
|
||||||
|
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||||
|
|
||||||
|
const bubble = screen.getByTestId("chat-user");
|
||||||
|
expect(bubble).not.toHaveTextContent("0123456789abcdef");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
|
||||||
|
expect(opened).toEqual([pdf]);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the image instead of its path, full size on click", () => {
|
it("shows the image instead of its path, full size on click", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||||
* layout), Main agent / System under their own label — instead of treating
|
* layout), Main agent / System under their own label — instead of treating
|
||||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||||
* and folds a run of tool calls into one line.
|
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||||
|
* shows as a chip.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { MessageList, workLabel, workingOn } from "../MessageList";
|
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
||||||
import type { TranscriptMessage } from "../../../lib/types";
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||||
@@ -22,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("MessageList — sender attribution", () => {
|
describe("MessageList — sender attribution", () => {
|
||||||
|
it("shows a typed /command and @path as chips in the user's bubble", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
messages={[
|
||||||
|
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
|
||||||
|
]}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const bubble = screen.getByTestId("chat-user");
|
||||||
|
expect(bubble).toHaveTextContent("/review @src/a.ts now");
|
||||||
|
expect(within(bubble).getByText("/review").className).toContain("text-accent");
|
||||||
|
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
|
||||||
|
});
|
||||||
|
|
||||||
it("labels each row by its sender, not blanket 'User'", () => {
|
it("labels each row by its sender, not blanket 'User'", () => {
|
||||||
const messages: TranscriptMessage[] = [
|
const messages: TranscriptMessage[] = [
|
||||||
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
||||||
@@ -126,11 +142,17 @@ describe("MessageList — sender attribution", () => {
|
|||||||
|
|
||||||
it("ends a live chat with the step still running", () => {
|
it("ends a live chat with the step still running", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
|
msg({
|
||||||
|
type: "user",
|
||||||
|
sender: "user",
|
||||||
|
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
||||||
|
content: [{ type: "text", text: "test it" }],
|
||||||
|
}),
|
||||||
msg({
|
msg({
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
sender: "assistant",
|
sender: "assistant",
|
||||||
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
timestamp: new Date(Date.now() - 5_000).toISOString(),
|
||||||
|
usage: { input_tokens: 2, output_tokens: 3500 },
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
@@ -144,7 +166,8 @@ describe("MessageList — sender attribution", () => {
|
|||||||
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
|
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
|
||||||
const row = screen.getByTestId("chat-running-step");
|
const row = screen.getByTestId("chat-running-step");
|
||||||
expect(row).toHaveTextContent("Run the full client suite");
|
expect(row).toHaveTextContent("Run the full client suite");
|
||||||
expect(row).toHaveTextContent(/1m \ds/);
|
// Like the terminal: the turn's time since the prompt, and its output.
|
||||||
|
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
||||||
// Its result arrived: nothing is running any more.
|
// Its result arrived: nothing is running any more.
|
||||||
rerender(
|
rerender(
|
||||||
<MessageList
|
<MessageList
|
||||||
@@ -159,6 +182,121 @@ describe("MessageList — sender attribution", () => {
|
|||||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #74: Esc before the model wrote anything takes the prompt back into
|
||||||
|
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
|
||||||
|
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
|
||||||
|
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
|
||||||
|
const prompt = msg({
|
||||||
|
sender: "user",
|
||||||
|
timestamp: new Date(Date.now() - 14_000).toISOString(),
|
||||||
|
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
|
||||||
|
});
|
||||||
|
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
|
||||||
|
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
|
||||||
|
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
|
||||||
|
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||||
|
// The next prompt is a new turn: its Thinking… shows.
|
||||||
|
const next = msg({
|
||||||
|
sender: "user",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
content: [{ type: "text", text: "again" }],
|
||||||
|
});
|
||||||
|
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
|
||||||
|
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a turn's output once per API call, since its prompt", () => {
|
||||||
|
const at = "2026-06-26T08:19:00.000Z";
|
||||||
|
const line = (cache: number, out: number) =>
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
content: [{ type: "text", text: "x" }],
|
||||||
|
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
|
||||||
|
});
|
||||||
|
const messages = [
|
||||||
|
msg({ sender: "user" }),
|
||||||
|
line(10, 999), // an earlier turn
|
||||||
|
msg({ sender: "user", timestamp: at }),
|
||||||
|
line(100, 40),
|
||||||
|
line(100, 40), // the same call's second block
|
||||||
|
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
|
||||||
|
line(200, 25),
|
||||||
|
];
|
||||||
|
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every parallel call of the turn running until its own result", () => {
|
||||||
|
const at = "2026-06-26T08:19:00.000Z";
|
||||||
|
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||||
|
const call = (id: string) =>
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
stop_reason: "tool_use",
|
||||||
|
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
|
||||||
|
});
|
||||||
|
const done = (id: string) =>
|
||||||
|
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
|
||||||
|
// Claude Code writes each parallel call on its own line; one result is in.
|
||||||
|
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
|
||||||
|
const results = new Map([["b", m[4]!.content[0]!]]);
|
||||||
|
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
|
||||||
|
// A call left unpaired in an EARLIER turn is not this one's.
|
||||||
|
const next = [
|
||||||
|
...m,
|
||||||
|
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
|
||||||
|
];
|
||||||
|
expect(workingOn(next, results, now)?.blocks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says when the CLI waits on the user, and offers the terminal", () => {
|
||||||
|
let opened = 0;
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
live
|
||||||
|
waiting
|
||||||
|
onOpenTerminal={() => opened++}
|
||||||
|
messages={[
|
||||||
|
msg({ sender: "user", timestamp: new Date().toISOString() }),
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const row = screen.getByTestId("chat-running-step");
|
||||||
|
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
|
||||||
|
fireEvent.click(screen.getByTestId("chat-open-terminal"));
|
||||||
|
expect(opened).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes an ended turn with its time and output", () => {
|
||||||
|
const reply = (out: number, stop_reason: string) =>
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: "2026-06-26T08:16:08.000Z",
|
||||||
|
stop_reason,
|
||||||
|
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
|
||||||
|
content: [{ type: "text", text: "done" }],
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
|
||||||
|
});
|
||||||
|
|
||||||
it("knows what a live turn is doing until it ends", () => {
|
it("knows what a live turn is doing until it ends", () => {
|
||||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||||
const at = "2026-06-26T08:19:00.000Z";
|
const at = "2026-06-26T08:19:00.000Z";
|
||||||
@@ -189,10 +327,11 @@ describe("MessageList — sender attribution", () => {
|
|||||||
);
|
);
|
||||||
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
|
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
|
||||||
|
|
||||||
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
|
const thinking = { blocks: [], since: null, last: at };
|
||||||
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
|
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
|
||||||
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
|
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
|
||||||
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
|
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
|
||||||
|
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
|
||||||
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
|
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
|
||||||
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
|
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
|
||||||
const stopped = msg({
|
const stopped = msg({
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @file AddLaneModal.tsx
|
* @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
|
* 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
|
* 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
|
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||||
* main repo you want stage detection on); "Worktree" provisions a
|
* main repo you want stage detection on); "Worktree" provisions a
|
||||||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with 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
|
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||||
|
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||||
|
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||||
|
* Settings → Git). The user also picks which CLI drives the lane
|
||||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||||
@@ -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`. */
|
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||||||
function Seg({
|
function Seg({
|
||||||
active,
|
active,
|
||||||
@@ -97,7 +111,18 @@ export function AddLaneModal({
|
|||||||
cwdSuggestions: CwdSuggestion[];
|
cwdSuggestions: CwdSuggestion[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
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 [sourceRepo, setSourceRepo] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [branch, setBranch] = useState("");
|
const [branch, setBranch] = useState("");
|
||||||
@@ -141,6 +166,10 @@ export function AddLaneModal({
|
|||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setMode("repo");
|
setMode("repo");
|
||||||
|
setCloneUrl("");
|
||||||
|
setCloneName("");
|
||||||
|
setLogin(null);
|
||||||
|
setSshRefused(false);
|
||||||
setSourceRepo("");
|
setSourceRepo("");
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setBranch("");
|
setBranch("");
|
||||||
@@ -213,6 +242,52 @@ export function AddLaneModal({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
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") {
|
if (mode === "repo") {
|
||||||
try {
|
try {
|
||||||
const result = await api.lanes.ensure({
|
const result = await api.lanes.ensure({
|
||||||
@@ -328,7 +403,6 @@ export function AddLaneModal({
|
|||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
const browseRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||||
|
|
||||||
// A summary on screen means the lane already exists and a step failed, so
|
// A summary on screen means the lane already exists and a step failed, so
|
||||||
@@ -338,6 +412,8 @@ export function AddLaneModal({
|
|||||||
const disabled =
|
const disabled =
|
||||||
!done &&
|
!done &&
|
||||||
(!sourceRepo.trim() ||
|
(!sourceRepo.trim() ||
|
||||||
|
(mode === "clone" &&
|
||||||
|
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
|
||||||
(mode === "worktree" &&
|
(mode === "worktree" &&
|
||||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||||
|
|
||||||
@@ -394,10 +470,38 @@ export function AddLaneModal({
|
|||||||
title={t("mode.worktreeHint")}
|
title={t("mode.worktreeHint")}
|
||||||
onClick={() => setMode("worktree")}
|
onClick={() => setMode("worktree")}
|
||||||
/>
|
/>
|
||||||
|
<Seg
|
||||||
|
active={mode === "clone"}
|
||||||
|
label={t("mode.clone")}
|
||||||
|
title={t("mode.cloneHint")}
|
||||||
|
onClick={() => setMode("clone")}
|
||||||
|
/>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
|
||||||
{noTools ? (
|
{noTools ? (
|
||||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
<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")}{" "}
|
{t("addLaneNoTools")}{" "}
|
||||||
@@ -458,17 +562,16 @@ export function AddLaneModal({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
<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>
|
</label>
|
||||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||||
{/* A click into the path opens the picker, as Browse does (the
|
{/* A click into the path only focuses it — the picker opens from
|
||||||
OS's own where it can be); typing still works from the keyboard. */}
|
Browse alone (#77). */}
|
||||||
<div
|
<div className="min-w-0 flex-1">
|
||||||
className="min-w-0 flex-1"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CwdAutocomplete
|
<CwdAutocomplete
|
||||||
inputId="add-lane-repo"
|
inputId="add-lane-repo"
|
||||||
value={sourceRepo}
|
value={sourceRepo}
|
||||||
@@ -476,18 +579,70 @@ export function AddLaneModal({
|
|||||||
suggestions={cwdSuggestions}
|
suggestions={cwdSuggestions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PathPickButton
|
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||||
start={sourceRepo}
|
|
||||||
onPick={setSourceRepo}
|
|
||||||
showLabel
|
|
||||||
buttonRef={browseRef}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] text-fg-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</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
|
{/* 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
|
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. */}
|
branch can be used at all is which worktree is holding it. */}
|
||||||
@@ -701,6 +856,14 @@ export function AddLaneModal({
|
|||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-status-danger">
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
|
{sshRefused && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<a href="/settings/git#git-identity" className="underline">
|
||||||
|
{t("clone.sshSettings")}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -721,7 +884,11 @@ export function AddLaneModal({
|
|||||||
data-tour="add-lane-create"
|
data-tour="add-lane-create"
|
||||||
className="btn-primary text-xs disabled:opacity-50"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||||
* a function that moves the cursor to the next / previous changed block.
|
* a function that moves the cursor to the next / previous changed block.
|
||||||
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||||
* Chat chip at the end of its first line (inline, so it covers no code),
|
* Chat chip floating above its last line, at the column where it ends,
|
||||||
* which hands the panel the selection's 1-based lines.
|
* which hands the panel the selection's 1-based lines.
|
||||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||||
@@ -50,6 +50,8 @@ import {
|
|||||||
highlightActiveLineGutter,
|
highlightActiveLineGutter,
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
|
showTooltip,
|
||||||
|
type Tooltip,
|
||||||
type DecorationSet,
|
type DecorationSet,
|
||||||
type ViewUpdate,
|
type ViewUpdate,
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
@@ -284,41 +286,34 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatChip extends WidgetType {
|
/** Add to Chat floating just above the selection's last line, at the column
|
||||||
constructor(
|
* where the selection ends — so a long line never pushes it out of view.
|
||||||
readonly start: number,
|
* `build` makes its DOM from the selection's first and last 1-based line. A
|
||||||
readonly end: number,
|
* selection ending at a line's start (a whole-line drag) does not take that
|
||||||
readonly build: (start: number, end: number) => HTMLElement
|
* line, and the chip then sits at the start of the last line it does take. */
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
eq(o: ChatChip) {
|
|
||||||
return this.start === o.start && this.end === o.end;
|
|
||||||
}
|
|
||||||
toDOM() {
|
|
||||||
return this.build(this.start, this.end);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add to Chat inline at the end of a non-empty selection's first line —
|
|
||||||
* the empty space right of the code, so it covers no line. `build` makes its
|
|
||||||
* DOM from the selection's first and last 1-based line. A selection ending
|
|
||||||
* at a line's start (a whole-line drag) does not take that line. */
|
|
||||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||||
return StateField.define<DecorationSet>({
|
return StateField.define<Tooltip | null>({
|
||||||
create: () => Decoration.none,
|
create: () => null,
|
||||||
update(deco, tr) {
|
update(tip, tr) {
|
||||||
if (!tr.docChanged && !tr.selection) return deco;
|
if (!tr.docChanged && !tr.selection) return tip;
|
||||||
const { from, to, empty } = tr.state.selection.main;
|
const { from, to, empty } = tr.state.selection.main;
|
||||||
if (empty) return Decoration.none;
|
if (empty) return null;
|
||||||
const doc = tr.state.doc;
|
const doc = tr.state.doc;
|
||||||
const first = doc.lineAt(from);
|
const first = doc.lineAt(from);
|
||||||
const last = doc.lineAt(to);
|
const last = doc.lineAt(to);
|
||||||
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
|
const wholeLine = to === last.from && last.number > first.number;
|
||||||
const widget = new ChatChip(first.number, end, build);
|
const end = wholeLine ? last.number - 1 : last.number;
|
||||||
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
|
return {
|
||||||
|
pos: wholeLine ? doc.line(end).from : to,
|
||||||
|
above: true,
|
||||||
|
create: () => {
|
||||||
|
const dom = build(first.number, end);
|
||||||
|
dom.classList.add("cm-chat-chip");
|
||||||
|
return { dom };
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => showTooltip.from(f),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,9 +350,19 @@ const theme = EditorView.theme({
|
|||||||
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||||
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
// Doubled classes: drawSelection's base theme scopes its light/dark colours
|
||||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
|
||||||
|
// plain `.cm-selectionBackground` never outranks.
|
||||||
|
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
|
||||||
|
{
|
||||||
|
background: "rgb(var(--accent) / 0.3)",
|
||||||
|
},
|
||||||
|
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
|
||||||
|
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
|
||||||
|
border: "1px solid rgb(var(--accent) / 0.6)",
|
||||||
|
backgroundColor: "rgb(var(--surface-1))",
|
||||||
},
|
},
|
||||||
|
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
|
||||||
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||||
[`${M} .cm-changedText`]: { background: "none" },
|
[`${M} .cm-changedText`]: { background: "none" },
|
||||||
@@ -544,7 +549,7 @@ export default function DiffEditor({
|
|||||||
b.dataset.testid = "lane-editor-add-to-chat";
|
b.dataset.testid = "lane-editor-add-to-chat";
|
||||||
b.textContent = cb.current.t("changes.addToChat");
|
b.textContent = cb.current.t("changes.addToChat");
|
||||||
b.className =
|
b.className =
|
||||||
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
|
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
|
||||||
// Keep the editor's focus (and so its selection) through the click.
|
// Keep the editor's focus (and so its selection) through the click.
|
||||||
b.onmousedown = (e) => e.preventDefault();
|
b.onmousedown = (e) => e.preventDefault();
|
||||||
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,178 @@
|
|||||||
/**
|
/**
|
||||||
* @file A server-backed folder browser for the Add Lane modal's path inputs.
|
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
||||||
* Browsers refuse to expose an absolute filesystem path from a native folder
|
* button, laid out like the OS's own picker. Browsers refuse to expose an
|
||||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
* absolute filesystem path from a native picker, so path selection here is
|
||||||
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
|
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
|
||||||
* up-one-level button and a click-to-descend list, since this tool is
|
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
|
||||||
* local-first and the server already trusts arbitrary local paths.
|
* (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>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
File,
|
||||||
|
FolderGit2,
|
||||||
|
FolderOpen,
|
||||||
|
FolderPlus,
|
||||||
|
Home,
|
||||||
|
Pencil,
|
||||||
|
Server,
|
||||||
|
Star,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { formatDateTime } from "../../lib/format";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
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,
|
open,
|
||||||
|
kind = "dir",
|
||||||
initialPath,
|
initialPath,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
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. */
|
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
onSelect: (path: string) => void;
|
onSelect: (path: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
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 [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);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setListing(await api.lanes.browse(path));
|
const next = await api.lanes.browse(target, kind === "file", hidden, host);
|
||||||
|
setListing(next);
|
||||||
|
setSelected(select);
|
||||||
|
setFilter("");
|
||||||
|
setEditing(false);
|
||||||
|
if (next.home) setHome(next.home);
|
||||||
|
setHostHome(next.hostHome ?? null);
|
||||||
|
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||||||
|
if (push) {
|
||||||
|
setHistory((h) => {
|
||||||
|
if (h.stack[h.at] === next.path) return h;
|
||||||
|
const stack = [...h.stack.slice(0, h.at + 1), next.path];
|
||||||
|
return { stack, at: stack.length - 1 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// Only re-run when the modal actually opens - not on every initialPath
|
||||||
// keystroke in the field behind it.
|
// keystroke in the field behind it.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -51,8 +183,171 @@ export function FolderBrowseModal({
|
|||||||
const panel = useRef<HTMLDivElement>(null);
|
const panel = useRef<HTMLDivElement>(null);
|
||||||
useDialog(open, panel, onClose);
|
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;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
@@ -61,74 +356,362 @@ export function FolderBrowseModal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={panel}
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={onPanelKey}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("browse.title")}
|
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||||||
data-tour="folder-browse"
|
data-tour="folder-browse"
|
||||||
>
|
>
|
||||||
<div className="border-b border-border p-3">
|
{/* Toolbar: history, up, and the breadcrumb / path field. */}
|
||||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||||
{listing?.path || "…"}
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-1">
|
{/* Footer: what will be picked, and the actions. */}
|
||||||
{error && (
|
<div className="border-t border-border p-2">
|
||||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
<div
|
||||||
{error}
|
// rtl so a long path loses its START to the ellipsis, not the folder.
|
||||||
</p>
|
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
|
||||||
)}
|
title={target ?? undefined}
|
||||||
{listing?.parent && (
|
>
|
||||||
|
<bdi>{target ?? "…"}</bdi>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => load(listing.parent!)}
|
onClick={() => {
|
||||||
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"
|
setNewName("");
|
||||||
|
setCreating(true);
|
||||||
|
}}
|
||||||
|
disabled={!listing}
|
||||||
|
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-3.5 w-3.5" />
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
..
|
{t("browse.newFolder")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
|
||||||
{listing?.entries.map((entry) => (
|
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
|
||||||
<button
|
<Eye className="h-3.5 w-3.5" />
|
||||||
key={entry.path}
|
{t("browse.showHidden")}
|
||||||
type="button"
|
</label>
|
||||||
onClick={() => load(entry.path)}
|
{hostHome && (
|
||||||
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"
|
<label
|
||||||
>
|
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
|
||||||
{entry.isGitRepo ? (
|
title={t("browse.hostFolderHint", { path: hostHome })}
|
||||||
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
|
>
|
||||||
) : (
|
<input
|
||||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
type="checkbox"
|
||||||
)}
|
checked={onHost}
|
||||||
<span className="truncate">{entry.name}</span>
|
onChange={toggleHost}
|
||||||
</button>
|
data-testid="browse-host-folder"
|
||||||
))}
|
/>
|
||||||
{listing && listing.entries.length === 0 && !listing.parent && (
|
<Server className="h-3.5 w-3.5" />
|
||||||
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
{t("browse.hostFolder")}
|
||||||
)}
|
</label>
|
||||||
</div>
|
)}
|
||||||
|
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
{t("browse.hint")}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
<div className="ml-auto flex items-center gap-2">
|
||||||
onClick={onClose}
|
<button
|
||||||
className="btn-ghost border border-border text-xs"
|
type="button"
|
||||||
>
|
onClick={onClose}
|
||||||
{t("destructive.cancel")}
|
className="btn-ghost border border-border text-xs"
|
||||||
</button>
|
>
|
||||||
<button
|
{t("destructive.cancel")}
|
||||||
type="button"
|
</button>
|
||||||
disabled={!listing}
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
if (listing) onSelect(listing.path);
|
disabled={!target}
|
||||||
onClose();
|
onClick={() => target && finish(target)}
|
||||||
}}
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
className="btn-primary text-xs disabled:opacity-50"
|
>
|
||||||
>
|
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
|
||||||
{t("browse.select")}
|
</button>
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import {
|
|||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Check,
|
Check,
|
||||||
|
Download,
|
||||||
Code2,
|
Code2,
|
||||||
Columns2,
|
Columns2,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -101,6 +102,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
UnfoldVertical,
|
UnfoldVertical,
|
||||||
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget } from "../../lib/api";
|
||||||
import { pasteToLane } from "../../lib/panePaste";
|
import { pasteToLane } from "../../lib/panePaste";
|
||||||
@@ -121,6 +123,7 @@ import {
|
|||||||
type LaneDiffChange,
|
type LaneDiffChange,
|
||||||
} from "./LaneDiffView";
|
} from "./LaneDiffView";
|
||||||
import { LaneConflictView } from "./LaneConflictView";
|
import { LaneConflictView } from "./LaneConflictView";
|
||||||
|
import { FileExplorer } from "./FileExplorer";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||||
@@ -939,6 +942,13 @@ export function LaneChangesPanel({
|
|||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
// The commit row amends the last commit instead of making a new one.
|
// The commit row amends the last commit instead of making a new one.
|
||||||
const [amend, setAmend] = useState(false);
|
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
|
// The files the commit takes (JetBrains' checkboxes). A path listed for the
|
||||||
// first time arrives with its default; `seenPaths` is the last full list,
|
// first time arrives with its default; `seenPaths` is the last full list,
|
||||||
// so a poll never re-ticks a file the user unticked.
|
// so a poll never re-ticks a file the user unticked.
|
||||||
@@ -1614,8 +1624,30 @@ export function LaneChangesPanel({
|
|||||||
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
||||||
{t("changes.showHistory")}
|
{t("changes.showHistory")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
testId="lane-changes-menu-download"
|
||||||
|
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||||||
|
>
|
||||||
|
{t("changes.download")}
|
||||||
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{treeCtx.menu.item.dir && (
|
||||||
|
<MenuItem
|
||||||
|
testId="lane-changes-menu-upload"
|
||||||
|
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
||||||
|
>
|
||||||
|
{t("changes.uploadHere")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{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))}>
|
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||||||
{t("changes.copyPath")}
|
{t("changes.copyPath")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -1861,6 +1893,61 @@ export function LaneChangesPanel({
|
|||||||
};
|
};
|
||||||
const [rereading, runReread] = useSpin(rereadAll);
|
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) =>
|
const toggleCollapsed = (path: string) =>
|
||||||
setCollapsed((prev) => {
|
setCollapsed((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -2047,6 +2134,36 @@ export function LaneChangesPanel({
|
|||||||
aria-label={t("changes.quickOpen")}
|
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"
|
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||||
/>
|
/>
|
||||||
|
<IconButton label={t("changes.openExplorer")} onClick={() => setExplorer({ path: "" })}>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
</IconButton>
|
||||||
|
<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 && (
|
{!hits && (
|
||||||
<IconButton
|
<IconButton
|
||||||
label={
|
label={
|
||||||
@@ -2566,13 +2683,16 @@ export function LaneChangesPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<label
|
<label
|
||||||
title={t("changes.amendHint")}
|
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
|
||||||
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
|
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
|
||||||
|
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid="lane-changes-amend"
|
data-testid="lane-changes-amend"
|
||||||
checked={amend}
|
checked={amend}
|
||||||
|
disabled={headPushed}
|
||||||
onChange={(e) => setAmend(e.target.checked)}
|
onChange={(e) => setAmend(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{t("changes.amend")}
|
{t("changes.amend")}
|
||||||
@@ -2758,6 +2878,18 @@ export function LaneChangesPanel({
|
|||||||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{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. */}
|
{/* Only the working file is editable: a commit is history. */}
|
||||||
{/* A working file opens in the editor; the read-only diff
|
{/* A working file opens in the editor; the read-only diff
|
||||||
(Whole file off) keeps an Edit button back into it. */}
|
(Whole file off) keeps an Edit button back into it. */}
|
||||||
|
|||||||
@@ -242,13 +242,27 @@ export function LaneGitBar({
|
|||||||
void run(action, body);
|
void run(action, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const push = () =>
|
const push = async () => {
|
||||||
confirmRun(
|
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
|
git.upstream
|
||||||
? {
|
? {
|
||||||
title: t("gitBar.push"),
|
title: t("gitBar.push"),
|
||||||
message: t("gitBar.pushConfirm", {
|
message: t("gitBar.pushConfirm", {
|
||||||
count: git.ahead ?? 0,
|
count: ahead,
|
||||||
branch: git.branch,
|
branch: git.branch,
|
||||||
upstream: git.upstream,
|
upstream: git.upstream,
|
||||||
}),
|
}),
|
||||||
@@ -259,6 +273,7 @@ export function LaneGitBar({
|
|||||||
},
|
},
|
||||||
"push"
|
"push"
|
||||||
);
|
);
|
||||||
|
};
|
||||||
const publish = () =>
|
const publish = () =>
|
||||||
confirmRun(
|
confirmRun(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* the sidebar's **New session** row). Every item is something the row already
|
* 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
|
* does: open it the way its click does (a live pane attaches, an ended session
|
||||||
* opens its conversation — never a resume), open a live one in a split,
|
* opens its conversation — never a resume), open a live one in a split,
|
||||||
* rename / archive / unarchive when the list offers them, copy its id, open
|
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
|
||||||
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
* its analysis page, and 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).
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -29,6 +29,7 @@ export function SessionMenu({
|
|||||||
onRename,
|
onRename,
|
||||||
onArchive,
|
onArchive,
|
||||||
onUnarchive,
|
onUnarchive,
|
||||||
|
onPin,
|
||||||
}: MenuPoint & {
|
}: MenuPoint & {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
@@ -38,6 +39,8 @@ export function SessionMenu({
|
|||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onUnarchive?: () => void;
|
onUnarchive?: () => void;
|
||||||
|
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||||
|
onPin?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -84,6 +87,11 @@ export function SessionMenu({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</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 && (
|
{session && (
|
||||||
<>
|
<>
|
||||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||||
|
|||||||
@@ -277,6 +277,13 @@ function HomeBody({
|
|||||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||||
});
|
});
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
// Here, not only through the effect: the navigate below unmounts this
|
||||||
|
// home in the same batch, so the effect never sees the empty prompt.
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(draftKey);
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable: nothing was kept */
|
||||||
|
}
|
||||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||||
* sidebar: the ones the workspace OWNS and has not archived
|
* sidebar: the ones the workspace OWNS and has not archived
|
||||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
|
* (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
|
||||||
* pane above the ended ones) — never the other
|
* 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
|
* sessions that ran in its folder. A row's dot
|
||||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
* 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
|
* which deletes nothing); a right-click opens the same actions and more
|
||||||
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
|
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
|
||||||
* carrying its session id) opens that tmux pane on the Workspace page
|
* carrying its session id) opens that tmux pane on the Workspace page
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { api, type RunHandle } from "../../lib/api";
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
@@ -278,17 +279,21 @@ export function WorkspaceSessionList({
|
|||||||
3000
|
3000
|
||||||
);
|
);
|
||||||
const refreshKey = `${hookKey}:${version}`;
|
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 sessions = own?.sessions ?? (own === null ? null : []);
|
||||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||||
// Live sessions first, ended ones under them — each group stays newest
|
// Pinned sessions first, then live ones, then ended ones — each group in
|
||||||
// first (the sort is stable over the server's order).
|
// 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 ?? [])
|
const shown = (sessions ?? [])
|
||||||
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
|
.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
|
// 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
|
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||||
// during a search — it has no name to match.
|
// during a search — it has no name to match.
|
||||||
@@ -336,7 +341,10 @@ export function WorkspaceSessionList({
|
|||||||
if (count !== null) onMatches?.(count);
|
if (count !== null) onMatches?.(count);
|
||||||
}, [count, onMatches]);
|
}, [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);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.sessions.patch(s.id, body);
|
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="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
|
||||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</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) && (
|
{sessionWorktreeName(lane, s) && (
|
||||||
<GitBranch
|
<GitBranch
|
||||||
data-testid={`workspace-session-worktree-${s.id}`}
|
data-testid={`workspace-session-worktree-${s.id}`}
|
||||||
@@ -540,7 +555,9 @@ export function WorkspaceSessionList({
|
|||||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
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)}
|
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||||
</span>
|
</span>
|
||||||
<AiToolIcon
|
<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"}`}
|
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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 && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -608,6 +641,9 @@ export function WorkspaceSessionList({
|
|||||||
onClose={sessionCtx.close}
|
onClose={sessionCtx.close}
|
||||||
onRename={() => startRename(sessionCtx.menu!.item)}
|
onRename={() => startRename(sessionCtx.menu!.item)}
|
||||||
onArchive={() => void archive(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(),
|
pipelines: vi.fn(),
|
||||||
worktree: vi.fn(),
|
worktree: vi.fn(),
|
||||||
ensure: vi.fn(),
|
ensure: vi.fn(),
|
||||||
|
clone: vi.fn(),
|
||||||
browse: vi.fn(),
|
browse: vi.fn(),
|
||||||
pickPath: 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(),
|
get: vi.fn(),
|
||||||
profileInit: vi.fn(),
|
profileInit: vi.fn(),
|
||||||
mcpSync: vi.fn(),
|
mcpSync: vi.fn(),
|
||||||
@@ -154,6 +157,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(api.lanes.branches).mockReset();
|
vi.mocked(api.lanes.branches).mockReset();
|
||||||
vi.mocked(api.lanes.worktree).mockReset();
|
vi.mocked(api.lanes.worktree).mockReset();
|
||||||
vi.mocked(api.lanes.ensure).mockReset();
|
vi.mocked(api.lanes.ensure).mockReset();
|
||||||
|
vi.mocked(api.lanes.clone).mockReset();
|
||||||
vi.mocked(api.lanes.browse).mockReset();
|
vi.mocked(api.lanes.browse).mockReset();
|
||||||
vi.mocked(api.lanes.pickPath).mockReset();
|
vi.mocked(api.lanes.pickPath).mockReset();
|
||||||
// Provisioning finishes instantly by default - tests that care about the
|
// Provisioning finishes instantly by default - tests that care about the
|
||||||
@@ -582,17 +586,15 @@ describe("AddLaneModal — folder browse", () => {
|
|||||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the picker on every click into the path field", async () => {
|
it("a click into the path field only focuses it — no picker opens (#77)", async () => {
|
||||||
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
|
|
||||||
renderModal();
|
renderModal();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const field = document.getElementById("add-lane-repo")!;
|
const field = document.getElementById("add-lane-repo")!;
|
||||||
|
|
||||||
await user.click(field);
|
await user.click(field);
|
||||||
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
|
expect(field).toHaveFocus();
|
||||||
await user.click(field);
|
expect(api.lanes.pickPath).not.toHaveBeenCalled();
|
||||||
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||||
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
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();
|
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();
|
noNativePicker();
|
||||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
path: "/Users/tester",
|
path: "/Users/tester",
|
||||||
@@ -627,8 +629,8 @@ describe("AddLaneModal — folder browse", () => {
|
|||||||
await user.click(screen.getByTitle("Browse for a folder"));
|
await user.click(screen.getByTitle("Browse for a folder"));
|
||||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByText("projects"));
|
await user.dblClick(screen.getByText("projects"));
|
||||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||||
@@ -693,7 +695,7 @@ describe("AddLaneModal — tool selection", () => {
|
|||||||
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||||
expect(picker).toHaveAttribute("aria-expanded", "false");
|
expect(picker).toHaveAttribute("aria-expanded", "false");
|
||||||
await user.click(picker);
|
await user.click(picker);
|
||||||
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
|
const list = screen.getByRole("group", { name: "Default AI tool" });
|
||||||
expect(
|
expect(
|
||||||
within(list)
|
within(list)
|
||||||
.getAllByRole("button")
|
.getAllByRole("button")
|
||||||
@@ -779,3 +781,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
|
|||||||
expect(b).not.toBeNull();
|
expect(b).not.toBeNull();
|
||||||
return b!;
|
return b!;
|
||||||
});
|
});
|
||||||
// Inline at the end of the first selected line, never a tooltip over the
|
// A tooltip above the last selected line, never inline at a line's end
|
||||||
// line above it.
|
// (a long line pushed it out of view).
|
||||||
expect(chip.closest(".cm-tooltip")).toBeNull();
|
expect(chip.classList.contains("cm-tooltip")).toBe(true);
|
||||||
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
expect(chip.closest(".cm-line")).toBeNull();
|
||||||
fireEvent.click(chip);
|
fireEvent.click(chip);
|
||||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||||
view.dispatch({ selection: { anchor: 6 } });
|
view.dispatch({ selection: { anchor: 6 } });
|
||||||
|
|||||||
@@ -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 { 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 userEvent from "@testing-library/user-event";
|
||||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||||
@@ -75,6 +75,9 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
gitTree: vi.fn(),
|
gitTree: vi.fn(),
|
||||||
gitTreeAll: vi.fn(),
|
gitTreeAll: vi.fn(),
|
||||||
gitBlame: vi.fn(),
|
gitBlame: vi.fn(),
|
||||||
|
gitDownloadUrl: (id: number, path: string) =>
|
||||||
|
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
|
||||||
|
gitUpload: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -545,6 +548,44 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", 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,
|
||||||
|
}));
|
||||||
|
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("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
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({
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { LaneGitFacts } from "../../../lib/types";
|
|||||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
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 {
|
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
||||||
@@ -164,6 +164,29 @@ describe("LaneGitBar", () => {
|
|||||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
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 () => {
|
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from "react-router-dom";
|
||||||
import type { Lane, Session } from "../../../lib/types";
|
import type { Lane, Session } from "../../../lib/types";
|
||||||
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
||||||
|
|
||||||
@@ -241,6 +241,27 @@ describe("WorkspaceHome", () => {
|
|||||||
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops the sent prompt's draft even though opening the session unmounts the home", async () => {
|
||||||
|
// As the Workspace page: `&run=` replaces the home with the session view.
|
||||||
|
function HomeUntilRun() {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
|
||||||
|
}
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="*" element={<HomeUntilRun />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
|
await user.type(screen.getByLabelText("How can I help you today?"), "sent once");
|
||||||
|
await user.click(screen.getByTestId("workspace-home-start"));
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("workspace-home-start")).toBeNull());
|
||||||
|
expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
|
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
|
||||||
renderHome();
|
renderHome();
|
||||||
await screen.findByTestId("workspace-recent-s-live");
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
|
|||||||
@@ -171,8 +171,16 @@ export function OnboardingTour() {
|
|||||||
style = { left: right + 16, top };
|
style = { left: right + 16, top };
|
||||||
} else if (rect.bottom + 180 < window.innerHeight) {
|
} else if (rect.bottom + 180 < window.innerHeight) {
|
||||||
style = { left, top: rect.bottom + 12 };
|
style = { left, top: rect.bottom + 12 };
|
||||||
|
} else if (rect.top > 200) {
|
||||||
|
style = { left, top: rect.top - 12, transform: "translateY(-100%)" };
|
||||||
} else {
|
} else {
|
||||||
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
|
// A target filling the screen (the live session): no room above or
|
||||||
|
// below, so the card sits inside its bottom-right corner.
|
||||||
|
style = {
|
||||||
|
left: Math.min(Math.max(12, rect.right - W - 16), window.innerWidth - W - 12),
|
||||||
|
top: Math.min(rect.bottom, window.innerHeight) - 16,
|
||||||
|
transform: "translateY(-100%)",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
* `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`,
|
* `~/.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
|
* 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
|
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
|
||||||
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
|
* text file it read or wrote 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
|
* `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
|
* 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.
|
* or the project's); a built-in skill has no file and shows by name only.
|
||||||
@@ -106,6 +107,14 @@ function useContent(path: string, scope: PreviewScope): Content {
|
|||||||
// An image it read elsewhere (a screenshot under ~/.cache): served
|
// An image it read elsewhere (a screenshot under ~/.cache): served
|
||||||
// only because this session's own Read named it.
|
// only because this session's own Read named it.
|
||||||
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
|
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" });
|
} else setContent({ kind: "none" });
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* @file ChatOutline.tsx
|
* @file ChatOutline.tsx
|
||||||
* @description The right-hand panel of a Chat pane, laid out like
|
* @description The right-hand panel of a Chat pane, laid out like
|
||||||
* Claude Desktop's activity panel: the session's Progress (its
|
* Claude Desktop's activity panel: the session's Progress (its
|
||||||
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
|
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one — how many
|
||||||
|
* steps are done of how many, a bar, and the step in progress highlighted), the Outputs
|
||||||
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
||||||
* this session" — the files attached to its prompts, the files it read and the
|
* this session" — the files attached to its prompts, the files it read and the
|
||||||
* memory files it used and its skills (a click opens the list with a
|
* memory files it used and its skills (a click opens the list with a
|
||||||
@@ -15,9 +16,17 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Check,
|
Check,
|
||||||
@@ -33,6 +42,8 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type SessionOutline } from "../../lib/api";
|
import { api, type SessionOutline } from "../../lib/api";
|
||||||
|
import { keepIfSame } from "../../lib/keepIfSame";
|
||||||
|
import { isShown } from "../../hooks/useOnScreen";
|
||||||
import { displayName, extOf } from "./ChatFilePreview";
|
import { displayName, extOf } from "./ChatFilePreview";
|
||||||
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
|
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
|
||||||
|
|
||||||
@@ -47,25 +58,30 @@ const EMPTY: SessionOutline = {
|
|||||||
mcp: [],
|
mcp: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The session's outline, re-read on a visibility-gated poll. */
|
/** The session's outline, re-read on a visibility-gated poll — skipped too
|
||||||
export function useSessionOutline(sessionId: string): SessionOutline {
|
* 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);
|
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
const load = () =>
|
const load = () =>
|
||||||
api.sessions
|
api.sessions
|
||||||
.outline(sessionId)
|
.outline(sessionId)
|
||||||
.then((o) => live && setOutline({ ...EMPTY, ...o }))
|
.then((o) => live && setOutline(keepIfSame<SessionOutline>({ ...EMPTY, ...o })))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
void load();
|
void load();
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
if (document.visibilityState === "visible") void load();
|
if (onScreen ? isShown(onScreen) : document.visibilityState === "visible") void load();
|
||||||
}, POLL_MS);
|
}, POLL_MS);
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId, onScreen]);
|
||||||
return outline;
|
return outline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +122,8 @@ function Section({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
count?: number;
|
/** Beside the title: a count, or a "3/7". */
|
||||||
|
count?: number | string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
/** Takes the panel's free height and scrolls inside it. */
|
/** Takes the panel's free height and scrolls inside it. */
|
||||||
grow?: boolean;
|
grow?: boolean;
|
||||||
@@ -209,6 +226,8 @@ export function ChatOutline({
|
|||||||
onOpenList: (list: OutlineList) => void;
|
onOpenList: (list: OutlineList) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
|
const href = useSettingsHref();
|
||||||
|
const doneSteps = o.progress.filter((p) => p.status === "completed").length;
|
||||||
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
|
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
|
||||||
const close = (
|
const close = (
|
||||||
<button
|
<button
|
||||||
@@ -226,23 +245,47 @@ export function ChatOutline({
|
|||||||
<aside
|
<aside
|
||||||
data-testid="chat-outline"
|
data-testid="chat-outline"
|
||||||
aria-label={t("chat.outline.panel")}
|
aria-label={t("chat.outline.panel")}
|
||||||
// Over the chat in a narrow pane (the container is PaneChat's root),
|
// Over the conversation in a narrow pane (the container is PaneChat's
|
||||||
|
// root; `--chat-area` keeps the prompt box below it uncovered),
|
||||||
// beside it once the pane has room, as wide as PaneChat's splitter says.
|
// beside it once the pane has room, as wide as PaneChat's splitter says.
|
||||||
className="absolute inset-y-3 right-3 z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
|
className="absolute right-3 top-3 bottom-[calc(100%-var(--chat-area,100%)+0.75rem)] z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
|
||||||
>
|
>
|
||||||
{o.progress.length > 0 && (
|
{o.progress.length > 0 && (
|
||||||
<Section title={t("chat.outline.progress")} action={close}>
|
<Section
|
||||||
|
title={t("chat.outline.progress")}
|
||||||
|
count={`${doneSteps}/${o.progress.length}`}
|
||||||
|
action={close}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={o.progress.length}
|
||||||
|
aria-valuenow={doneSteps}
|
||||||
|
aria-label={t("chat.outline.progress")}
|
||||||
|
className="mx-1.5 mb-1.5 h-1 overflow-hidden rounded-full bg-surface-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent-solid transition-[width] duration-500"
|
||||||
|
style={{ width: `${(doneSteps / o.progress.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{o.progress.map((task) => {
|
{o.progress.map((task) => {
|
||||||
const done = task.status === "completed";
|
const done = task.status === "completed";
|
||||||
|
const now = task.status === "in_progress";
|
||||||
return (
|
return (
|
||||||
<div key={task.id} className={rowClass} title={task.subject}>
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`${rowClass} ${now ? "bg-accent/10 font-medium" : ""}`}
|
||||||
|
title={task.subject}
|
||||||
|
aria-current={now ? "step" : undefined}
|
||||||
|
>
|
||||||
{done ? (
|
{done ? (
|
||||||
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
|
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<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
|
<span
|
||||||
@@ -329,7 +372,7 @@ export function ChatOutline({
|
|||||||
icon={Plug}
|
icon={Plug}
|
||||||
label={t("chat.outline.connectors")}
|
label={t("chat.outline.connectors")}
|
||||||
names={o.mcp}
|
names={o.mcp}
|
||||||
to="/settings/agents/claude-code?tab=mcp"
|
to={href("agents/claude-code?tab=mcp")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
* one, up to four panes). A right-click on a tab
|
* one, up to four panes). A right-click on a tab
|
||||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||||
* and `workspaceOf` names each tab's workspace before its own name.
|
* and `workspaceOf` names each tab's workspace before its own name. Settings
|
||||||
|
* opened on a workspace is one more tab at the bar's end (`settings`, kept
|
||||||
|
* until its X), shown instead of the panes while it is the active one.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -43,8 +45,9 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import type { RunHandle } from "../../lib/api";
|
import type { RunHandle } from "../../lib/api";
|
||||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
@@ -152,6 +155,9 @@ interface Tab {
|
|||||||
|
|
||||||
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
||||||
|
|
||||||
|
/** The Settings tab's key among the tabs' (a group's key is its slots). */
|
||||||
|
const SETTINGS_KEY = "settings";
|
||||||
|
|
||||||
export function ConsoleTabs({
|
export function ConsoleTabs({
|
||||||
lane,
|
lane,
|
||||||
tabs: runs,
|
tabs: runs,
|
||||||
@@ -164,6 +170,7 @@ export function ConsoleTabs({
|
|||||||
sessions,
|
sessions,
|
||||||
onRenamed,
|
onRenamed,
|
||||||
workspaceOf,
|
workspaceOf,
|
||||||
|
settings,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** The workspace's live runs, in slot order. */
|
/** The workspace's live runs, in slot order. */
|
||||||
@@ -187,9 +194,12 @@ export function ConsoleTabs({
|
|||||||
onHome?: () => void;
|
onHome?: () => void;
|
||||||
/** Right end of the bar (the single pane's split menus). */
|
/** Right end of the bar (the single pane's split menus). */
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
|
/** The Settings tab, while it is open: on screen (`active`), shown, closed. */
|
||||||
|
settings?: { active: boolean; onOpen: () => void; onClose: () => void } | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const settingsHref = useSettingsHref();
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
// The tab being renamed (its run id) and the draft name.
|
// The tab being renamed (its run id) and the draft name.
|
||||||
@@ -397,8 +407,9 @@ export function ConsoleTabs({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
|
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
|
||||||
|
const keys = [...tabs.map((x) => x.key), ...(settings ? [SETTINGS_KEY] : [])];
|
||||||
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
|
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
|
||||||
const last = tabs.length - 1;
|
const last = keys.length - 1;
|
||||||
const to =
|
const to =
|
||||||
e.key === "ArrowRight"
|
e.key === "ArrowRight"
|
||||||
? i === last
|
? i === last
|
||||||
@@ -413,11 +424,14 @@ export function ConsoleTabs({
|
|||||||
: e.key === "End"
|
: e.key === "End"
|
||||||
? last
|
? last
|
||||||
: null;
|
: null;
|
||||||
if (to === null || !tabs[to]) return;
|
const key = to === null ? undefined : keys[to];
|
||||||
|
if (key === undefined) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
tabRefs.current.get(tabs[to].key)?.focus();
|
tabRefs.current.get(key)?.focus();
|
||||||
};
|
};
|
||||||
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
|
const tabStop = settings?.active
|
||||||
|
? SETTINGS_KEY
|
||||||
|
: (tabs.find((x) => x.index === groups.active)?.key ?? keys[0]);
|
||||||
|
|
||||||
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||||
const pickTab = (fn: (tab: Tab) => unknown) => () => {
|
const pickTab = (fn: (tab: Tab) => unknown) => () => {
|
||||||
@@ -456,7 +470,7 @@ export function ConsoleTabs({
|
|||||||
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
|
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
|
||||||
>
|
>
|
||||||
{tabs.map((tab, i) => {
|
{tabs.map((tab, i) => {
|
||||||
const active = tab.index === groups.active;
|
const active = !settings?.active && tab.index === groups.active;
|
||||||
const single = tab.runs.length === 1 ? tab.focus : null;
|
const single = tab.runs.length === 1 ? tab.focus : null;
|
||||||
const state = tabState(tab);
|
const state = tabState(tab);
|
||||||
if (single && renaming === single.id) {
|
if (single && renaming === single.id) {
|
||||||
@@ -569,6 +583,48 @@ export function ConsoleTabs({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{settings && (
|
||||||
|
<div
|
||||||
|
className={`group relative flex shrink-0 items-center rounded-t-md ${
|
||||||
|
settings.active
|
||||||
|
? "bg-surface-0 text-fg-primary"
|
||||||
|
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{settings.active && (
|
||||||
|
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={settings.active}
|
||||||
|
tabIndex={tabStop === SETTINGS_KEY ? 0 : -1}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) tabRefs.current.set(SETTINGS_KEY, el);
|
||||||
|
else tabRefs.current.delete(SETTINGS_KEY);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => onTabKey(e, keys.length - 1)}
|
||||||
|
data-testid="console-tab-settings"
|
||||||
|
onClick={settings.onOpen}
|
||||||
|
className="flex h-full items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 shrink-0" />
|
||||||
|
{t("tabs.settings")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="console-tab-settings-close"
|
||||||
|
onClick={settings.onClose}
|
||||||
|
aria-label={t("tabs.closeSettings")}
|
||||||
|
title={t("tabs.closeSettings")}
|
||||||
|
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
|
||||||
|
settings.active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div ref={menuRef} className="relative flex items-center px-1.5">
|
<div ref={menuRef} className="relative flex items-center px-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -618,7 +674,10 @@ export function ConsoleTabs({
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={`${item} !text-fg-muted`}
|
className={`${item} !text-fg-muted`}
|
||||||
onClick={() => navigate("/settings/agents")}
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
navigate(settingsHref("agents"));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4" />
|
<Settings2 className="h-4 w-4" />
|
||||||
{t("tabs.agentSettings")}
|
{t("tabs.agentSettings")}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { AlertCircle, Plus } from "lucide-react";
|
import { AlertCircle, Plus } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
@@ -89,6 +89,13 @@ export function LaneConsolePane({
|
|||||||
: null;
|
: null;
|
||||||
const chat = useSessionChat(chatSession);
|
const chat = useSessionChat(chatSession);
|
||||||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
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(() => {
|
useEffect(() => {
|
||||||
onHasActiveRunChange?.(handle !== null);
|
onHasActiveRunChange?.(handle !== null);
|
||||||
@@ -210,13 +217,11 @@ export function LaneConsolePane({
|
|||||||
runId={handle.id}
|
runId={handle.id}
|
||||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
autoFocus={autoFocus && !(chatable && chat)}
|
autoFocus={autoFocus && !(chatable && chat)}
|
||||||
|
covered={!!(chatable && chat)}
|
||||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||||
// the tab bar moves on (another tab, or the home's prompt box)
|
// the tab bar moves on (another tab, or the home's prompt box)
|
||||||
// rather than leaving a dead terminal the user has to click out of.
|
// rather than leaving a dead terminal the user has to click out of.
|
||||||
onExit={() => {
|
onExit={onTerminalExit}
|
||||||
setHandle(null);
|
|
||||||
onRunExit?.();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,11 +12,18 @@
|
|||||||
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
||||||
* Shift+Tab changes in a running session, so the server steps it and reads
|
* Shift+Tab changes in a running session, so the server steps it and reads
|
||||||
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
||||||
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
|
||||||
|
* permission or trust prompt, Codex's approval — is read off the terminal
|
||||||
|
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
|
||||||
|
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
|
||||||
|
* a field in the card, whose text is typed there as the answer
|
||||||
|
* (`answerRunMenu`).
|
||||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||||
* outputs, what the session used), open at first in a pane spanning the
|
* outputs, what the session used), open at first in a pane spanning the
|
||||||
* grid's full width or height (`panelOpen`) and collapsed into a file button
|
* grid's full width or height (`panelOpen`) once the session has an output,
|
||||||
* with the output count in a quarter-sized one; it sits beside the
|
* else collapsed into a file button with the output count; the user's own
|
||||||
|
* open/close is kept per session for the tab (sessionStorage), so the chat
|
||||||
|
* leaving for the terminal and back keeps it; it sits beside the
|
||||||
* conversation when the pane is wide and over it when narrow (a CSS container
|
* conversation when the pane is wide and over it when narrow (a CSS container
|
||||||
* query on the pane, not the window), dragged wider or narrower by its
|
* query on the pane, not the window), dragged wider or narrower by its
|
||||||
* splitter there (a fraction of the pane, remembered per browser). A file
|
* splitter there (a fraction of the pane, remembered per browser). A file
|
||||||
@@ -28,18 +35,29 @@
|
|||||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||||
* "Edited N files" card opens in the Workspace's Changes panel
|
* "Edited N files" card opens in the Workspace's Changes panel
|
||||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||||
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
|
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||||
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
|
* leaves them as typed; switching to the terminal pastes them into its input
|
||||||
|
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
|
||||||
* conversation to its end and keeps it there (`follow`), so the message and
|
* conversation to its end and keeps it there (`follow`), so the message and
|
||||||
* the step running after it come into sight on their own. While the session's
|
* the step running after it come into sight on their own. While the session's
|
||||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||||
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
|
* Stop in Send's place: Esc pressed in the pane (`interruptRun`). While the
|
||||||
|
* CLI asks the user something (the main agent awaiting input on a
|
||||||
|
* Notification — a permission prompt), the chat's live line says so and
|
||||||
|
* offers the terminal (`showTerminal`); before the session has started (no
|
||||||
|
* row yet: a trust prompt, say, answered only there) anything typed into the
|
||||||
|
* pane is confirmed first, so an Enter does not pick that prompt's
|
||||||
|
* preselected "No, exit" unasked. The box shows how full the context
|
||||||
|
* is (the newest reply's prompt size against the model's window,
|
||||||
|
* `contextWindow`), and a hidden browser tab is marked while the session
|
||||||
|
* waits on the user or once its turn ended (`lib/tabBadge.ts`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { ConversationView } from "../conversation/ConversationView";
|
import { ConversationView } from "../conversation/ConversationView";
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||||
import { ChatAddMenu } from "./ChatAddMenu";
|
import { ChatAddMenu } from "./ChatAddMenu";
|
||||||
@@ -48,11 +66,23 @@ import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
|||||||
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||||
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { Divider } from "./PaneGrid";
|
import { Divider, showTerminal } from "./PaneGrid";
|
||||||
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
import {
|
||||||
|
answerRunMenu,
|
||||||
|
chooseRunOption,
|
||||||
|
interruptRun,
|
||||||
|
readRunMenu,
|
||||||
|
submitToRun,
|
||||||
|
type PaneMenu,
|
||||||
|
} from "../../lib/panePaste";
|
||||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
|
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||||
import { openInChanges } from "../../lib/openInChanges";
|
import { openInChanges } from "../../lib/openInChanges";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { setTabBadge } from "../../lib/tabBadge";
|
||||||
|
import { fmt } from "../../lib/format";
|
||||||
|
import { agentAwaitingReason } from "../../lib/types";
|
||||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||||
|
|
||||||
const MODES: [PermissionMode, string][] = [
|
const MODES: [PermissionMode, string][] = [
|
||||||
@@ -65,11 +95,68 @@ const MODES: [PermissionMode, string][] = [
|
|||||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||||
|
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||||
|
const readOutlineChoice = (sessionId: string) => {
|
||||||
|
const v = sessionStorage.getItem(outlineKey(sessionId));
|
||||||
|
return v === null ? null : v === "1";
|
||||||
|
};
|
||||||
const storedSide = () => {
|
const storedSide = () => {
|
||||||
const f = Number(localStorage.getItem(SIDE_KEY));
|
const f = Number(localStorage.getItem(SIDE_KEY));
|
||||||
return f ? clampSide(f) : null;
|
return f ? clampSide(f) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The model's context window. Claude's 200K models are the 4.5 line and
|
||||||
|
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
|
||||||
|
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||||
|
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||||
|
export function contextWindow(model: string | null): number | null {
|
||||||
|
if (!model?.startsWith("claude-")) return null;
|
||||||
|
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
|
||||||
|
? 200_000
|
||||||
|
: 1_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How full the context is: a ring and a percentage, amber then red as
|
||||||
|
* Claude Code's auto-compact comes near. */
|
||||||
|
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const size = contextWindow(model);
|
||||||
|
if (!size || !tokens) return null;
|
||||||
|
const pct = Math.min(100, Math.round((tokens / size) * 100));
|
||||||
|
const tone =
|
||||||
|
pct >= 90 ? "text-status-danger" : pct >= 75 ? "text-status-warning" : "text-fg-muted";
|
||||||
|
const ring = 2 * Math.PI * 6;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="pane-chat-context"
|
||||||
|
title={t("chat.contextTitle", { used: fmt(tokens), size: fmt(size) })}
|
||||||
|
className={`flex items-center gap-1 px-1 text-xs tabular-nums ${tone}`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 -rotate-90" aria-hidden>
|
||||||
|
<circle
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity="0.25"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray={`${(pct / 100) * ring} ${ring}`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The pane's permission mode as it shows it; a pick steps it there. */
|
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||||
const { t } = useTranslation(["run", "lanes"]);
|
const { t } = useTranslation(["run", "lanes"]);
|
||||||
@@ -118,7 +205,112 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaneChat({
|
/** 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. */
|
||||||
|
export function PaneMenuCard({
|
||||||
|
menu,
|
||||||
|
onChoose,
|
||||||
|
onAnswer,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
menu: PaneMenu;
|
||||||
|
onChoose: (index: number) => void;
|
||||||
|
onAnswer: (text: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const [typing, setTyping] = useState(false);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const submit = () => draft.trim() && onAnswer(draft);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="pane-chat-menu"
|
||||||
|
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||||
|
>
|
||||||
|
{menu.question && (
|
||||||
|
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||||
|
{menu.question}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{menu.options.map((o, i) =>
|
||||||
|
typing && i === menu.typeAt ? (
|
||||||
|
<div
|
||||||
|
key={`${i}-type`}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1"
|
||||||
|
>
|
||||||
|
<span className="w-4 flex-shrink-0 font-mono text-xs text-fg-muted">{o.n}</span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
data-testid="pane-chat-menu-input"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
} else if (e.key === "Escape") setTyping(false);
|
||||||
|
}}
|
||||||
|
placeholder={t("chat.menu.placeholder")}
|
||||||
|
aria-label={t("chat.menu.placeholder")}
|
||||||
|
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="pane-chat-menu-send"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
aria-label={t("chat.send")}
|
||||||
|
title={t("chat.send")}
|
||||||
|
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={`${i}-${o.label}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => (i === menu.typeAt ? setTyping(true) : onChoose(i))}
|
||||||
|
className={`flex items-start gap-2 rounded-lg border px-3 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 ${
|
||||||
|
i === menu.cursor ? "border-accent/50 bg-surface-2" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 w-4 flex-shrink-0 font-mono text-xs text-fg-muted">
|
||||||
|
{o.checked === null ? (
|
||||||
|
(o.n ?? "↵")
|
||||||
|
) : o.checked ? (
|
||||||
|
<SquareCheck className="h-3.5 w-3.5 text-accent" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 break-words">
|
||||||
|
{o.label}
|
||||||
|
{o.hint && <span className="block text-xs text-fg-muted">{o.hint}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-fg-muted">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="ml-auto rounded-md border border-border px-2 py-0.5 hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{t("chat.menu.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
run,
|
||||||
sessionId,
|
sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -147,11 +339,20 @@ export function PaneChat({
|
|||||||
const [model, setModel] = useState(run.model ?? "");
|
const [model, setModel] = useState(run.model ?? "");
|
||||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||||
const pickFiles = useRef<(() => void) | null>(null);
|
const pickFiles = useRef<(() => void) | null>(null);
|
||||||
const [outlineOpen, setOutlineOpen] = useState(panelOpen);
|
// The user's own open/close, per session for this tab: it outlives the chat
|
||||||
|
// leaving for the terminal (an unmount). Unset, the default rules.
|
||||||
|
const [outlineChoice, setOutlineChoice] = useState(() => readOutlineChoice(sessionId));
|
||||||
// A split or a close changes the pane's shape: its default comes back.
|
// A split or a close changes the pane's shape: its default comes back.
|
||||||
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]);
|
const shape = useRef(panelOpen);
|
||||||
|
useEffect(() => {
|
||||||
|
if (shape.current === panelOpen) return;
|
||||||
|
shape.current = panelOpen;
|
||||||
|
sessionStorage.removeItem(outlineKey(sessionId));
|
||||||
|
setOutlineChoice(null);
|
||||||
|
}, [panelOpen, sessionId]);
|
||||||
const openOutline = (open: boolean) => {
|
const openOutline = (open: boolean) => {
|
||||||
setOutlineOpen(open);
|
sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
|
||||||
|
setOutlineChoice(open);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
};
|
};
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
@@ -160,20 +361,44 @@ export function PaneChat({
|
|||||||
const [dragged, setDragged] = useState(storedSide);
|
const [dragged, setDragged] = useState(storedSide);
|
||||||
const side = dragged ?? (preview ? 0.45 : 0.3);
|
const side = dragged ?? (preview ? 0.45 : 0.3);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
// The conversation's height, so a panel laid over a narrow pane stops above
|
||||||
|
// the prompt box instead of covering it.
|
||||||
|
const areaRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const area = areaRef.current;
|
||||||
|
if (!area || typeof ResizeObserver === "undefined") return;
|
||||||
|
const ro = new ResizeObserver(() =>
|
||||||
|
rootRef.current?.style.setProperty("--chat-area", `${area.offsetHeight}px`)
|
||||||
|
);
|
||||||
|
ro.observe(area);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
const [list, setList] = useState<OutlineList | null>(null);
|
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 };
|
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||||
const text = promptWithImages(prompt, images);
|
const text = promptWithImages(prompt, images);
|
||||||
|
|
||||||
// The main agent is "working" from a prompt's submit to its Stop hook.
|
// The main agent is "working" from a prompt's submit to its Stop hook, and
|
||||||
|
// waits on the user from a Notification (a permission prompt) to the next hook.
|
||||||
const [working, setWorking] = useState(false);
|
const [working, setWorking] = useState(false);
|
||||||
|
const [waiting, setWaiting] = useState(false);
|
||||||
|
// Stop presses, for the conversation's live line (issue #74).
|
||||||
|
const [stops, setStops] = useState(0);
|
||||||
const readWorking = useCallback(
|
const readWorking = useCallback(
|
||||||
() =>
|
() =>
|
||||||
api.agents
|
api.agents
|
||||||
.list({ session_id: sessionId, status: "working" })
|
.list({ session_id: sessionId, status: "working" })
|
||||||
// With session_id the server answers every agent of the session, its
|
// With session_id the server answers every agent of the session, its
|
||||||
// status filter ignored: check the status here.
|
// status filter ignored: check the status here.
|
||||||
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
|
.then((r) => {
|
||||||
|
const main = r.agents.filter((a) => a.type === "main");
|
||||||
|
setWorking(main.some((a) => a.status === "working"));
|
||||||
|
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||||
|
})
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
[sessionId]
|
[sessionId]
|
||||||
);
|
);
|
||||||
@@ -185,22 +410,102 @@ export function PaneChat({
|
|||||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||||
// another dashboard on the same database, or the socket dropped) would
|
// another dashboard on the same database, or the socket dropped) would
|
||||||
// otherwise leave Stop up after the turn ended.
|
// otherwise leave Stop up after the turn ended.
|
||||||
intervalMs: working ? 5000 : undefined,
|
intervalMs: working || waiting ? 5000 : undefined,
|
||||||
match: (msg) =>
|
match: (msg) =>
|
||||||
msg.type === "agent_updated" &&
|
msg.type === "agent_updated" &&
|
||||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The menu the terminal underneath shows now. AskUserQuestion fires no
|
||||||
|
// Notification, so this — not `waiting` alone — says the CLI asks.
|
||||||
|
// ponytail: polls the xterm buffer; subscribe to its onRender if it ever shows in a profile.
|
||||||
|
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const read = () =>
|
||||||
|
setMenu((prev) => {
|
||||||
|
const next = readRunMenu(runId);
|
||||||
|
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||||
|
});
|
||||||
|
read();
|
||||||
|
// 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, onScreen]);
|
||||||
|
const asking = waiting || menu !== null;
|
||||||
|
// Gone until the pane redraws: no second press on a stale menu.
|
||||||
|
const choose = (index: number) => {
|
||||||
|
if (menu && chooseRunOption(runId, menu, index)) setMenu(null);
|
||||||
|
else toast.error(t("chat.notSent"));
|
||||||
|
};
|
||||||
|
const answer = (reply: string) => {
|
||||||
|
if (menu && answerRunMenu(runId, menu, reply)) {
|
||||||
|
setMenu(null);
|
||||||
|
setFollow((n) => n + 1);
|
||||||
|
} else toast.error(t("chat.notSent"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// A hidden tab says when this session waits on the user or its turn ended.
|
||||||
|
const was = useRef({ working: false, asking: false });
|
||||||
|
useEffect(() => {
|
||||||
|
if (asking) setTabBadge(runId, "waiting");
|
||||||
|
else if (working) setTabBadge(runId, null);
|
||||||
|
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
||||||
|
was.current = { working, asking };
|
||||||
|
}, [runId, working, asking]);
|
||||||
|
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
||||||
|
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
||||||
|
tokens: 0,
|
||||||
|
model: null,
|
||||||
|
});
|
||||||
|
const onContext = useCallback(
|
||||||
|
(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(
|
||||||
|
(path: string) => {
|
||||||
|
// 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
|
||||||
|
// answer depends on Claude Code firing a hook then, and a hard lock would
|
||||||
|
// strand the chat if it does not.
|
||||||
|
const [notStarted, setNotStarted] = useState(false);
|
||||||
|
|
||||||
// A control is a command typed into the pane, like the user would.
|
// A control is a command typed into the pane, like the user would.
|
||||||
const command = (line: string) => {
|
const command = async (line: string) => {
|
||||||
|
if (
|
||||||
|
notStarted &&
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("chat.notStartedTitle"),
|
||||||
|
message: t("chat.notStarted"),
|
||||||
|
confirmLabel: t("chat.sendAnyway"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
const ok = submitToRun(runId, line);
|
const ok = submitToRun(runId, line);
|
||||||
if (!ok) toast.error(t("chat.notSent"));
|
if (!ok) toast.error(t("chat.notSent"));
|
||||||
return ok;
|
return ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
const send = () => {
|
const send = async () => {
|
||||||
if (!text || uploading) return;
|
if (!text || uploading) return;
|
||||||
if (!command(text)) return;
|
if (!(await command(text))) return;
|
||||||
setFollow((n) => n + 1);
|
setFollow((n) => n + 1);
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
@@ -215,18 +520,18 @@ export function PaneChat({
|
|||||||
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||||
>
|
>
|
||||||
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
||||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
<div ref={areaRef} className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
<ConversationView
|
<ConversationView
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
fill
|
fill
|
||||||
live
|
live
|
||||||
follow={follow}
|
follow={follow}
|
||||||
onOpenFile={(path) => {
|
waiting={asking}
|
||||||
// The workspace's Changes panel when the file is in its folder;
|
stopped={stops}
|
||||||
// else this view's own preview.
|
onOpenTerminal={openTerminal}
|
||||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
onContext={onContext}
|
||||||
setPreview(path);
|
onNotStarted={setNotStarted}
|
||||||
}}
|
onOpenFile={openFile}
|
||||||
/>
|
/>
|
||||||
{!outlineOpen && !preview && (
|
{!outlineOpen && !preview && (
|
||||||
<button
|
<button
|
||||||
@@ -244,7 +549,24 @@ export function PaneChat({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mx-4">
|
<div className="mx-4">
|
||||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||||
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
{menu && (
|
||||||
|
<PaneMenuCard
|
||||||
|
// A new question starts with its field closed.
|
||||||
|
key={menu.question}
|
||||||
|
menu={menu}
|
||||||
|
onChoose={choose}
|
||||||
|
onAnswer={answer}
|
||||||
|
onCancel={() => {
|
||||||
|
if (interruptRun(runId)) setMenu(null);
|
||||||
|
else toast.error(t("chat.notSent"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* In the card's place while the CLI asks: hidden, never unmounted,
|
||||||
|
so a draft or an upload in flight survives the question. */}
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
|
||||||
|
>
|
||||||
<PromptEditor
|
<PromptEditor
|
||||||
bare
|
bare
|
||||||
images={images}
|
images={images}
|
||||||
@@ -253,7 +575,7 @@ export function PaneChat({
|
|||||||
pickFiles={pickFiles}
|
pickFiles={pickFiles}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={setPrompt}
|
onChange={setPrompt}
|
||||||
onSubmit={send}
|
onSubmit={() => void send()}
|
||||||
placeholder={t("chat.placeholder")}
|
placeholder={t("chat.placeholder")}
|
||||||
rows={2}
|
rows={2}
|
||||||
slash={claude}
|
slash={claude}
|
||||||
@@ -271,16 +593,17 @@ export function PaneChat({
|
|||||||
/>
|
/>
|
||||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<ContextMeter tokens={context.tokens} model={context.model} />
|
||||||
{claude && (
|
{claude && (
|
||||||
<ModelMenu
|
<ModelMenu
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(v) => {
|
onChange={async (v) => {
|
||||||
if (command(`/model ${v || "default"}`)) setModel(v);
|
if (await command(`/model ${v || "default"}`)) setModel(v);
|
||||||
}}
|
}}
|
||||||
tool={run.tool}
|
tool={run.tool}
|
||||||
effort={effort}
|
effort={effort}
|
||||||
onEffortChange={(e) => {
|
onEffortChange={async (e) => {
|
||||||
if (command(`/effort ${e || "auto"}`)) setEffort(e);
|
if (await command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -290,8 +613,10 @@ export function PaneChat({
|
|||||||
data-testid="pane-chat-stop"
|
data-testid="pane-chat-stop"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// An interrupted turn fires no Stop hook: clear it here.
|
// An interrupted turn fires no Stop hook: clear it here.
|
||||||
if (interruptRun(runId)) setWorking(false);
|
if (interruptRun(runId)) {
|
||||||
else toast.error(t("chat.notStopped"));
|
setWorking(false);
|
||||||
|
setStops((n) => n + 1);
|
||||||
|
} else toast.error(t("chat.notStopped"));
|
||||||
}}
|
}}
|
||||||
aria-label={t("chat.stop")}
|
aria-label={t("chat.stop")}
|
||||||
title={t("chat.stop")}
|
title={t("chat.stop")}
|
||||||
@@ -303,7 +628,7 @@ export function PaneChat({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="pane-chat-send"
|
data-testid="pane-chat-send"
|
||||||
onClick={send}
|
onClick={() => void send()}
|
||||||
disabled={uploading || !text}
|
disabled={uploading || !text}
|
||||||
aria-label={t("chat.send")}
|
aria-label={t("chat.send")}
|
||||||
title={t("chat.send")}
|
title={t("chat.send")}
|
||||||
@@ -324,7 +649,7 @@ export function PaneChat({
|
|||||||
scope={scope}
|
scope={scope}
|
||||||
onClose={() => setPreview(null)}
|
onClose={() => setPreview(null)}
|
||||||
// Beside the chat when the pane has room, over it when not.
|
// Beside the chat when the pane has room, over it when not.
|
||||||
className="absolute inset-0 z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
outlineOpen && (
|
outlineOpen && (
|
||||||
@@ -373,4 +698,4 @@ export function PaneChat({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
|
* 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
|
* 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.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -58,6 +61,9 @@ import {
|
|||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||||
|
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||||
|
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||||
|
import { promptWithImages } from "./RunSetup";
|
||||||
|
|
||||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||||
@@ -195,7 +201,18 @@ const ITEM =
|
|||||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||||
|
|
||||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
|
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
||||||
|
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
||||||
|
* the draft (its files as paths) back into the input box, never sent. */
|
||||||
|
/** A session's chat switched to its terminal: the unsent prompt goes into
|
||||||
|
* the CLI's input box, without Enter. */
|
||||||
|
export function showTerminal(runId: string, sessionId: string) {
|
||||||
|
const draft = readChatDraft(sessionId);
|
||||||
|
const text = promptWithImages(draft.prompt, draft.images);
|
||||||
|
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
|
||||||
|
setSessionChat(sessionId, false);
|
||||||
|
}
|
||||||
|
|
||||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const chat = useSessionChat(run?.sessionId);
|
const chat = useSessionChat(run?.sessionId);
|
||||||
@@ -209,7 +226,14 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
|||||||
aria-pressed={chat}
|
aria-pressed={chat}
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => setSessionChat(sessionId, !chat)}
|
onClick={() => {
|
||||||
|
if (chat) return showTerminal(run.id, sessionId);
|
||||||
|
const draft = readChatDraft(sessionId);
|
||||||
|
const text = takeRunInput(run.id);
|
||||||
|
if (text)
|
||||||
|
writeChatDraft(sessionId, [draft.prompt, text].filter(Boolean).join("\n"), draft.images);
|
||||||
|
setSessionChat(sessionId, true);
|
||||||
|
}}
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
{chat ? (
|
{chat ? (
|
||||||
@@ -344,8 +368,14 @@ export function PaneGrid({
|
|||||||
onNewSplit,
|
onNewSplit,
|
||||||
stateOf,
|
stateOf,
|
||||||
onStop,
|
onStop,
|
||||||
|
keyOf = (slot) => slot,
|
||||||
|
kept = [],
|
||||||
}: {
|
}: {
|
||||||
layout: PaneLayout;
|
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). */
|
/** Stops the session in `slot` (the header's X). */
|
||||||
onStop?: (slot: number) => void;
|
onStop?: (slot: number) => void;
|
||||||
/** A live AI run's state for its header dot (none for a shell). */
|
/** A live AI run's state for its header dot (none for a shell). */
|
||||||
@@ -469,13 +499,25 @@ export function PaneGrid({
|
|||||||
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
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 r = rectOf.get(slot);
|
||||||
const run = runOf(slot);
|
const run = runOf(slot);
|
||||||
const focused = layout.focus === slot;
|
const focused = layout.focus === slot;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={slot}
|
key={key}
|
||||||
data-testid={`pane-${slot}`}
|
data-testid={`pane-${slot}`}
|
||||||
data-focused={r && focused ? "true" : undefined}
|
data-focused={r && focused ? "true" : undefined}
|
||||||
// A menu opened in this pane stays inside it (`menuRoom`).
|
// A menu opened in this pane stays inside it (`menuRoom`).
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||||
|
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||||
|
* the textarea (`lib/promptTokens.ts`);
|
||||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||||
@@ -61,6 +63,7 @@ import { Select, menuRoom } from "../Select";
|
|||||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { ImageLightbox } from "../ImageLightbox";
|
import { ImageLightbox } from "../ImageLightbox";
|
||||||
|
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||||
import {
|
import {
|
||||||
ATTACH_MIME,
|
ATTACH_MIME,
|
||||||
addAttachments,
|
addAttachments,
|
||||||
@@ -224,6 +227,15 @@ export function PromptEditor({
|
|||||||
}: PromptEditorProps) {
|
}: PromptEditorProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
// With a `/command` or `@path` in it, the textarea's own text turns
|
||||||
|
// transparent and a mirror over it — same box, font and wrapping, scrolled
|
||||||
|
// with it — draws the text with those tokens coloured.
|
||||||
|
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tokens = useMemo(() => promptTokens(value), [value]);
|
||||||
|
const marked = tokens.some((tok) => tok.kind !== "text");
|
||||||
|
const box = bare
|
||||||
|
? "block max-h-[40vh] w-full overflow-y-auto px-1 py-1 text-sm"
|
||||||
|
: "w-full rounded-md border px-3 py-2 text-[11px] font-mono";
|
||||||
// A composer grows with its text: reset to its rows, then take what the
|
// A composer grows with its text: reset to its rows, then take what the
|
||||||
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -232,6 +244,10 @@ export function PromptEditor({
|
|||||||
ta.style.height = "auto";
|
ta.style.height = "auto";
|
||||||
ta.style.height = `${ta.scrollHeight}px`;
|
ta.style.height = `${ta.scrollHeight}px`;
|
||||||
}, [bare, value]);
|
}, [bare, value]);
|
||||||
|
// A mirror that just appeared starts at the top, whatever the textarea shows.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (mirrorRef.current && taRef.current) mirrorRef.current.scrollTop = taRef.current.scrollTop;
|
||||||
|
}, [marked]);
|
||||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [files, setFiles] = useState<string[]>([]);
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
@@ -492,7 +508,7 @@ export function PromptEditor({
|
|||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
title={img.path}
|
title={img.path}
|
||||||
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
className="flex h-14 w-40 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
<span className="truncate">{img.name}</span>
|
<span className="truncate">{img.name}</span>
|
||||||
@@ -545,39 +561,64 @@ export function PromptEditor({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<div className="relative">
|
||||||
ref={taRef}
|
<textarea
|
||||||
value={value}
|
ref={taRef}
|
||||||
onChange={(e) => {
|
onScroll={(e) => {
|
||||||
onChange(e.target.value);
|
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||||
retrack(e.target);
|
}}
|
||||||
setActive(0);
|
value={value}
|
||||||
}}
|
onChange={(e) => {
|
||||||
onSelect={(e) => retrack(e.currentTarget)}
|
onChange(e.target.value);
|
||||||
onBlur={() => setTrigger(null)}
|
retrack(e.target);
|
||||||
onKeyDown={handleKeyDown}
|
setActive(0);
|
||||||
onPaste={(e) => {
|
}}
|
||||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
onSelect={(e) => retrack(e.currentTarget)}
|
||||||
else {
|
onBlur={() => setTrigger(null)}
|
||||||
// A very long paste goes in as a text file, like Claude Desktop:
|
onKeyDown={handleKeyDown}
|
||||||
// the prompt then carries its path, never the text (#68: a CLI's
|
onPaste={(e) => {
|
||||||
// argument and a tmux message both have a size limit).
|
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
else {
|
||||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
// A very long paste goes in as a text file, like Claude Desktop:
|
||||||
e.preventDefault();
|
// the prompt then carries its path, never the text (#68: a CLI's
|
||||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
// argument and a tmux message both have a size limit).
|
||||||
|
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||||
|
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||||
|
e.preventDefault();
|
||||||
|
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
placeholder={placeholder}
|
||||||
placeholder={placeholder}
|
rows={rows}
|
||||||
rows={rows}
|
spellCheck={false}
|
||||||
spellCheck={false}
|
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
|
||||||
className={
|
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
|
||||||
bare
|
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
|
||||||
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
/>
|
||||||
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
{marked && (
|
||||||
}
|
<div
|
||||||
/>
|
ref={mirrorRef}
|
||||||
|
aria-hidden
|
||||||
|
data-testid="prompt-mirror"
|
||||||
|
// Its own scrollbar keeps the width the textarea's takes, but is
|
||||||
|
// invisible, so the real one shows through.
|
||||||
|
className={`${box} pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words border-transparent text-fg-primary [scrollbar-color:transparent_transparent]`}
|
||||||
|
>
|
||||||
|
{tokens.map((tok, i) =>
|
||||||
|
tok.kind === "text" ? (
|
||||||
|
tok.text
|
||||||
|
) : (
|
||||||
|
<span key={i} className={`rounded-sm ${TOKEN_TONE[tok.kind]}`}>
|
||||||
|
{tok.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{/* A trailing newline is a line in a textarea, not in a div. */}
|
||||||
|
{"\u200b"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{upload && (
|
{upload && (
|
||||||
<p
|
<p
|
||||||
role={upload === "failed" ? "alert" : "status"}
|
role={upload === "failed" ? "alert" : "status"}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
* (`loadXterm`), not bundled with the Workspace chunk.
|
* (`loadXterm`), not bundled with the Workspace chunk.
|
||||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||||
* Files (images or any other) dropped on it are saved on the server
|
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
* local terminal does on a drop, 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
|
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||||
* pasted into whichever of the workspace's panes was focused last.
|
* pasted into whichever of the workspace's panes was focused last.
|
||||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||||
@@ -25,11 +25,15 @@
|
|||||||
* from its terminal — announced by a hint the textarea is described by;
|
* from its terminal — announced by a hint the textarea is described by;
|
||||||
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
|
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
|
||||||
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
|
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
|
||||||
|
* An input method that edits the textarea without composition events (macOS
|
||||||
|
* Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone
|
||||||
|
* dropped its capitals and the words it rewrites.
|
||||||
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
|
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
|
||||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
* (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
|
* 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
|
* 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
|
* 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
|
* 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
|
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||||
@@ -37,7 +41,16 @@
|
|||||||
* here takes the lead back (`resize` with `drive: true`).
|
* here takes the lead back (`resize` with `drive: true`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
import {
|
||||||
|
memo,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ClipboardEvent,
|
||||||
|
type DragEvent,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||||
@@ -48,10 +61,13 @@ import "@xterm/xterm/css/xterm.css";
|
|||||||
import { useTheme } from "../../hooks/useTheme";
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { 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 { 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). */
|
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||||
@@ -73,9 +89,11 @@ const clipboardProvider: IClipboardProvider = {
|
|||||||
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
|
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
|
||||||
// clipboard. An empty write (a "clear") is ignored.
|
// clipboard. An empty write (a "clear") is ignored.
|
||||||
writeText: (_selection, text) => {
|
writeText: (_selection, text) => {
|
||||||
// Silent and best effort: no toast per copy, and on a non-secure origin
|
// Silent and best effort: no toast per copy. On a non-secure origin
|
||||||
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
|
// (`http://<lan-ip>`, no Clipboard API) writeClipboard falls back to the
|
||||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
// 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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,6 +140,10 @@ export function loadXterm(): Promise<Xterm> {
|
|||||||
const RETRY_MIN_MS = 1500;
|
const RETRY_MIN_MS = 1500;
|
||||||
const RETRY_MAX_MS = 30_000;
|
const RETRY_MAX_MS = 30_000;
|
||||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
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 {
|
interface TerminalViewProps {
|
||||||
runId: string;
|
runId: string;
|
||||||
@@ -133,9 +155,22 @@ interface TerminalViewProps {
|
|||||||
/** Take the keyboard when it appears (default). Off for a split pane that
|
/** 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. */
|
* is not the focused one, or keys would land in whichever mounted last. */
|
||||||
autoFocus?: boolean;
|
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);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||||
// every render, and as an effect dep it would tear the terminal down and
|
// every render, and as an effect dep it would tear the terminal down and
|
||||||
@@ -184,11 +219,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDrop = async (e: DragEvent) => {
|
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
const ctrlVRef = useRef(false);
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
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();
|
e.preventDefault();
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
const files = Array.from(e.dataTransfer.files);
|
void attachFiles(Array.from(e.dataTransfer.files));
|
||||||
|
};
|
||||||
|
|
||||||
|
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||||
|
// text/plain and would paste nothing. Capture phase, because xterm's own
|
||||||
|
// paste listener stops the event. Text is xterm's to paste, Ctrl+V's too;
|
||||||
|
// only a Ctrl+V with nothing on the clipboard is the pane's ^V again
|
||||||
|
// (vim's block select, readline's quoted insert).
|
||||||
|
const onPaste = (e: ClipboardEvent) => {
|
||||||
|
const ctrlV = ctrlVRef.current;
|
||||||
|
ctrlVRef.current = false;
|
||||||
|
const files = Array.from(e.clipboardData?.files ?? []);
|
||||||
|
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (files.length > 0) void attachFiles(files);
|
||||||
|
else termRef.current?.input("\x16", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachFiles = async (files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
setDrop("uploading");
|
setDrop("uploading");
|
||||||
try {
|
try {
|
||||||
@@ -233,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,
|
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||||
// and why it only showed up once content scrolled.
|
// and why it only showed up once content scrolled.
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
fontSize: 13,
|
fontSize: readTerminalFontSize(),
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||||
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
|
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
|
||||||
@@ -327,6 +393,19 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
requestAnimationFrame(() => findInput.current?.focus());
|
requestAnimationFrame(() => findInput.current?.focus());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Ctrl+V: xterm would send ^V, and claude would then read the
|
||||||
|
// SERVER's clipboard, never this browser's. Let the browser paste
|
||||||
|
// instead (onPaste: a file's path, or the text); a browser that fires
|
||||||
|
// no paste, or one with nothing on the clipboard, still sends ^V.
|
||||||
|
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
|
||||||
|
ctrlVRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!ctrlVRef.current) return;
|
||||||
|
ctrlVRef.current = false;
|
||||||
|
term.input("\x16", true);
|
||||||
|
}, 0);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
@@ -486,6 +565,12 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
const reassert = takeLead;
|
const reassert = takeLead;
|
||||||
term.textarea?.addEventListener("focus", reassert);
|
term.textarea?.addEventListener("focus", reassert);
|
||||||
|
|
||||||
|
// macOS's own Vietnamese Telex (Safari and Chrome) types into the
|
||||||
|
// textarea with no composition events: a capital arrives while Shift is
|
||||||
|
// held and every later key REPLACES the word — both dropped by xterm
|
||||||
|
// (xterm.js #5887). lib/imeInput.ts sends those edits itself.
|
||||||
|
const detachIme = attachImeInput(term);
|
||||||
|
|
||||||
const refit = () => {
|
const refit = () => {
|
||||||
// Never fit a collapsed console — see sendSize.
|
// Never fit a collapsed console — see sendSize.
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -506,6 +591,17 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
refit();
|
refit();
|
||||||
};
|
};
|
||||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
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 () => {
|
return () => {
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
@@ -513,7 +609,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
searchRef.current = null;
|
searchRef.current = null;
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||||
|
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||||
term.textarea?.removeEventListener("focus", reassert);
|
term.textarea?.removeEventListener("focus", reassert);
|
||||||
|
detachIme();
|
||||||
resizeDisposable.dispose();
|
resizeDisposable.dispose();
|
||||||
dataDisposable.dispose();
|
dataDisposable.dispose();
|
||||||
ws?.close();
|
ws?.close();
|
||||||
@@ -562,6 +660,35 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||||
(data) => {
|
(data) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
|
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, reuse);
|
||||||
|
// Width 0: the right half of a wide character.
|
||||||
|
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return { rows, cols: term.cols };
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
[runId]
|
[runId]
|
||||||
@@ -601,25 +728,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"
|
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
|
||||||
style={{ backgroundColor: palette.background }}
|
style={{ backgroundColor: palette.background }}
|
||||||
onDragEnter={(e) => {
|
onDragEnter={(e) => {
|
||||||
if (!hasFiles(e)) return;
|
if (!hasFiles(e) && !hasText(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth.current += 1;
|
dragDepth.current += 1;
|
||||||
setDrop("hint");
|
setDrop(hasFiles(e) ? "hint" : "paste");
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
if (!hasFiles(e)) return;
|
if (!hasFiles(e) && !hasText(e)) return;
|
||||||
e.preventDefault(); // without it the browser refuses the drop
|
e.preventDefault(); // without it the browser refuses the drop
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
}}
|
}}
|
||||||
onDragLeave={(e) => {
|
onDragLeave={(e) => {
|
||||||
if (!hasFiles(e)) return;
|
if (!hasFiles(e) && !hasText(e)) return;
|
||||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
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={(e) => void onDrop(e)}
|
onDrop={onDrop}
|
||||||
|
onPasteCapture={onPaste}
|
||||||
onFocus={() => notePaneFocus(runId)}
|
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">
|
<p id={hintId} className="sr-only">
|
||||||
{t("terminal.keyboardHint")}
|
{t("terminal.keyboardHint")}
|
||||||
</p>
|
</p>
|
||||||
@@ -720,4 +853,4 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* pane's folder through the lane's git file read, a memory file under
|
* 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
|
* `~/.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
|
* 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
|
* list dialog previewing the file picked in it; and the skills dialog finding
|
||||||
* each skill's SKILL.md by name.
|
* each skill's SKILL.md by name.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -31,7 +31,14 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
gitRawUrl: () => "",
|
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: {
|
ccConfig: {
|
||||||
skills: vi.fn().mockResolvedValue({
|
skills: vi.fn().mockResolvedValue({
|
||||||
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
|
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();
|
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={() => {}} />);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ describe("ChatOutline", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("Parse plan").className).toContain("line-through");
|
expect(screen.getByText("Parse plan").className).toContain("line-through");
|
||||||
expect(screen.getByText("Build report").className).not.toContain("line-through");
|
expect(screen.getByText("Build report").className).not.toContain("line-through");
|
||||||
|
// How far the plan is, and which step is being worked on.
|
||||||
|
expect(screen.getByText("1/2")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
|
||||||
|
expect(screen.getByTitle("Build report")).toHaveAttribute("aria-current", "step");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
|
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
|
||||||
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
||||||
|
|||||||
@@ -7,30 +7,52 @@
|
|||||||
* activity panel, collapsed into a file button with the output count, whose
|
* activity panel, collapsed into a file button with the output count, whose
|
||||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||||
* Stop in Send's place while the main agent works, pressing Esc in the pane.
|
* Stop in Send's place while the main agent works, pressing Esc in the pane;
|
||||||
|
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
||||||
|
* message sent meanwhile typed as its "Type something" answer.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PaneChat } from "../PaneChat";
|
import { PaneChat, contextWindow } from "../PaneChat";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import { interruptRun, submitToRun } from "../../../lib/panePaste";
|
import {
|
||||||
|
answerRunMenu,
|
||||||
|
chooseRunOption,
|
||||||
|
interruptRun,
|
||||||
|
readRunMenu,
|
||||||
|
submitToRun,
|
||||||
|
type PaneMenu,
|
||||||
|
} from "../../../lib/panePaste";
|
||||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||||
|
|
||||||
vi.mock("../../conversation/ConversationView", () => ({
|
vi.mock("../../conversation/ConversationView", () => ({
|
||||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||||
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
|
ConversationView: ({
|
||||||
|
onOpenFile,
|
||||||
|
onNotStarted,
|
||||||
|
}: {
|
||||||
|
onOpenFile?: (path: string) => void;
|
||||||
|
onNotStarted?: (notStarted: boolean) => void;
|
||||||
|
}) => (
|
||||||
<div data-testid="conversation">
|
<div data-testid="conversation">
|
||||||
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||||
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||||
|
{/* The session has no row yet: the CLI has not started it. */}
|
||||||
|
<button data-testid="not-started" onClick={() => onNotStarted?.(true)} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
|
||||||
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||||
vi.mock("../../../lib/panePaste", () => ({
|
vi.mock("../../../lib/panePaste", () => ({
|
||||||
submitToRun: vi.fn(() => true),
|
submitToRun: vi.fn(() => true),
|
||||||
interruptRun: vi.fn(() => true),
|
interruptRun: vi.fn(() => true),
|
||||||
|
readRunMenu: vi.fn(() => null),
|
||||||
|
chooseRunOption: vi.fn(() => true),
|
||||||
|
answerRunMenu: vi.fn(() => true),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
@@ -65,6 +87,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
skills: [],
|
skills: [],
|
||||||
mcp: [],
|
mcp: [],
|
||||||
}),
|
}),
|
||||||
|
readFile: r({ text: "", truncated: false }),
|
||||||
},
|
},
|
||||||
lanes: {
|
lanes: {
|
||||||
gitFile: r({
|
gitFile: r({
|
||||||
@@ -126,6 +149,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(submitToRun).mockClear();
|
vi.mocked(submitToRun).mockClear();
|
||||||
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
||||||
localStorage.removeItem("agenthub.chat.draft.s1");
|
localStorage.removeItem("agenthub.chat.draft.s1");
|
||||||
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PaneChat — the + menu", () => {
|
describe("PaneChat — the + menu", () => {
|
||||||
@@ -200,6 +224,44 @@ describe("PaneChat — Stop", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PaneChat — waiting on the user", () => {
|
||||||
|
it("marks a hidden tab while a permission prompt waits", async () => {
|
||||||
|
const vis = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
||||||
|
document.title = "Workspace · AgentHub";
|
||||||
|
try {
|
||||||
|
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
type: "main",
|
||||||
|
status: "waiting",
|
||||||
|
awaiting_input_since: "2026-10-12T08:00:00.000Z",
|
||||||
|
awaiting_reason: "notification",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as never);
|
||||||
|
const { unmount } = renderChat();
|
||||||
|
await waitFor(() => expect(document.title).toBe("● Waiting · Workspace · AgentHub"));
|
||||||
|
unmount();
|
||||||
|
expect(document.title).toBe("Workspace · AgentHub");
|
||||||
|
} finally {
|
||||||
|
vis.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contextWindow", () => {
|
||||||
|
it("knows Claude's 200K line from the 1M one", () => {
|
||||||
|
expect(contextWindow("claude-opus-4-5-20251101")).toBe(200_000);
|
||||||
|
expect(contextWindow("claude-sonnet-4-20250514")).toBe(200_000);
|
||||||
|
expect(contextWindow("claude-3-7-sonnet-20250219")).toBe(200_000);
|
||||||
|
expect(contextWindow("claude-opus-4-6")).toBe(1_000_000);
|
||||||
|
expect(contextWindow("claude-opus-5-5")).toBe(1_000_000);
|
||||||
|
expect(contextWindow("claude-haiku-5-5")).toBe(1_000_000);
|
||||||
|
expect(contextWindow("gpt-5-codex")).toBeNull();
|
||||||
|
expect(contextWindow(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PaneChat — model, effort and permission mode", () => {
|
describe("PaneChat — model, effort and permission mode", () => {
|
||||||
it("types /model into the pane", async () => {
|
it("types /model into the pane", async () => {
|
||||||
renderChat();
|
renderChat();
|
||||||
@@ -253,27 +315,65 @@ describe("PaneChat — the activity panel", () => {
|
|||||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => {
|
it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => {
|
||||||
const pane = (panelOpen: boolean) => (
|
const pane = (panelOpen: boolean) => (
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
const { rerender } = render(pane(true));
|
const { rerender } = render(pane(true));
|
||||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
await screen.findByTestId("chat-outline");
|
||||||
rerender(pane(false));
|
rerender(pane(false));
|
||||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||||
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes from its splitter and remembers the width", () => {
|
it("starts closed while the session has no output, even in a spanning pane", async () => {
|
||||||
|
vi.mocked(api.sessions.outline).mockResolvedValueOnce({
|
||||||
|
progress: [],
|
||||||
|
outputs: [],
|
||||||
|
files: [],
|
||||||
|
uploads: [],
|
||||||
|
memory: [],
|
||||||
|
skills: [],
|
||||||
|
mcp: [],
|
||||||
|
} as never);
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(api.sessions.outline).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||||
|
expect(screen.getByTestId("chat-outline-show").textContent).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the user's open or close across the chat leaving for the terminal", async () => {
|
||||||
|
const pane = () => (
|
||||||
|
<MemoryRouter>
|
||||||
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const first = render(pane());
|
||||||
|
fireEvent.click(
|
||||||
|
within(await screen.findByTestId("chat-outline")).getByRole("button", {
|
||||||
|
name: "Hide side panel",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
first.unmount();
|
||||||
|
render(pane());
|
||||||
|
await waitFor(() => expect(screen.getByTestId("chat-outline-show").textContent).toBe("2"));
|
||||||
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes from its splitter and remembers the width", async () => {
|
||||||
localStorage.removeItem("agenthub.chat.panelWidth");
|
localStorage.removeItem("agenthub.chat.panelWidth");
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
const splitter = screen.getByRole("separator", { name: "Resize side panel" });
|
const splitter = await screen.findByRole("separator", { name: "Resize side panel" });
|
||||||
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
||||||
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
||||||
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
||||||
@@ -333,4 +433,82 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
||||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
it("asks before sending while the session has not started", async () => {
|
||||||
|
vi.mocked(submitToRun).mockClear();
|
||||||
|
confirmDialog.mockClear();
|
||||||
|
renderChat();
|
||||||
|
fireEvent.click(screen.getByTestId("not-started"));
|
||||||
|
const box = screen.getByPlaceholderText("Write a message…");
|
||||||
|
fireEvent.change(box, { target: { value: "hello" } });
|
||||||
|
confirmDialog.mockResolvedValueOnce(false);
|
||||||
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
|
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
|
||||||
|
expect(submitToRun).not.toHaveBeenCalled();
|
||||||
|
expect(box).toHaveValue("hello");
|
||||||
|
confirmDialog.mockResolvedValueOnce(true);
|
||||||
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
|
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends without asking once the session has started", async () => {
|
||||||
|
vi.mocked(submitToRun).mockClear();
|
||||||
|
confirmDialog.mockClear();
|
||||||
|
renderChat();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
|
||||||
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
|
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
|
||||||
|
expect(confirmDialog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PaneChat — a menu the CLI asks with", () => {
|
||||||
|
const MENU: PaneMenu = {
|
||||||
|
question: "Which color?",
|
||||||
|
options: [
|
||||||
|
{ n: 1, label: "Red", hint: "warm", checked: null },
|
||||||
|
{ n: 2, label: "Type something.", hint: "", checked: null },
|
||||||
|
],
|
||||||
|
cursor: 0,
|
||||||
|
multi: false,
|
||||||
|
typeAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("shows the question as buttons in the box's place, and a pick presses its key", async () => {
|
||||||
|
vi.mocked(readRunMenu).mockReturnValue(MENU);
|
||||||
|
try {
|
||||||
|
renderChat();
|
||||||
|
const card = await screen.findByTestId("pane-chat-menu");
|
||||||
|
expect(card).toHaveTextContent("Which color?");
|
||||||
|
expect(card).toHaveTextContent("warm");
|
||||||
|
// The prompt box is hidden (kept mounted) while the CLI asks.
|
||||||
|
expect(screen.getByPlaceholderText("Write a message…").closest(".hidden")).not.toBeNull();
|
||||||
|
fireEvent.click(screen.getByText("Red"));
|
||||||
|
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", MENU, 0);
|
||||||
|
} finally {
|
||||||
|
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("types the answer in the card's own field, never pressing that row's key", async () => {
|
||||||
|
vi.mocked(readRunMenu).mockReturnValue(MENU);
|
||||||
|
vi.mocked(chooseRunOption).mockClear();
|
||||||
|
vi.mocked(submitToRun).mockClear();
|
||||||
|
try {
|
||||||
|
renderChat();
|
||||||
|
fireEvent.click(await screen.findByText("Type something."));
|
||||||
|
expect(chooseRunOption).not.toHaveBeenCalled();
|
||||||
|
const field = screen.getByTestId("pane-chat-menu-input");
|
||||||
|
expect(field).toHaveFocus();
|
||||||
|
fireEvent.change(field, { target: { value: "purple" } });
|
||||||
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
|
expect(answerRunMenu).toHaveBeenCalledWith("agenthub-lane-1", MENU, "purple");
|
||||||
|
expect(submitToRun).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||||
* pasted image shown as a thumbnail and sent as its saved path (and
|
* pasted image shown as a thumbnail and sent as its saved path (and
|
||||||
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
||||||
* chip, a composer
|
* chip, `/command` and `@path` coloured by a mirror over the text, a composer
|
||||||
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
||||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import {
|
import {
|
||||||
@@ -117,6 +117,31 @@ function renderEditor(value: string, slash = true) {
|
|||||||
const box = () => screen.getByPlaceholderText("Ask anything…");
|
const box = () => screen.getByPlaceholderText("Ask anything…");
|
||||||
|
|
||||||
describe("PromptEditor — / and @ suggestions", () => {
|
describe("PromptEditor — / and @ suggestions", () => {
|
||||||
|
it("colours /command and @path tokens through a mirror, only when there are any", () => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<PromptEditor
|
||||||
|
value={"/review @src/a.ts please"}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash
|
||||||
|
fileCwd="/Users/tester"
|
||||||
|
images={[]}
|
||||||
|
onImagesChange={() => {}}
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const mirror = screen.getByTestId("prompt-mirror");
|
||||||
|
expect(mirror).toHaveAttribute("aria-hidden");
|
||||||
|
expect(within(mirror).getByText("/review").className).toContain("text-accent");
|
||||||
|
expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet");
|
||||||
|
expect(box().className).toContain("text-transparent");
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderEditor("plain words");
|
||||||
|
expect(screen.queryByTestId("prompt-mirror")).toBeNull();
|
||||||
|
expect(box().className).toContain("text-fg-primary");
|
||||||
|
});
|
||||||
|
|
||||||
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||||
const spies = renderEditor("");
|
const spies = renderEditor("");
|
||||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* @file TerminalView.test.tsx
|
* @file TerminalView.test.tsx
|
||||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||||
* a WS connection to the right URL, writes incoming binary frames to the
|
* a WS connection to the right URL, writes incoming binary frames to the
|
||||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
* mocked terminal, forwards typed input (Safari Telex word replacements
|
||||||
|
* included) as outgoing binary frames, and
|
||||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||||
* after `exit` or a policy close,
|
* after `exit` or a policy close,
|
||||||
* and recolours live on a dark-theme change without re-attaching.
|
* and recolours live on a dark-theme change without re-attaching.
|
||||||
@@ -21,6 +22,9 @@ const wheelHandlerMock = vi.fn();
|
|||||||
const keyHandlerMock = vi.fn();
|
const keyHandlerMock = vi.fn();
|
||||||
const focusMock = vi.fn();
|
const focusMock = vi.fn();
|
||||||
const pasteMock = vi.fn();
|
const pasteMock = vi.fn();
|
||||||
|
const inputMock = vi.fn();
|
||||||
|
// The newest terminal's hidden input, where the browser's IME types.
|
||||||
|
let textarea: HTMLTextAreaElement;
|
||||||
const dropFileMock = vi.fn();
|
const dropFileMock = vi.fn();
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||||
@@ -69,6 +73,10 @@ vi.mock("@xterm/xterm", () => ({
|
|||||||
attachCustomKeyEventHandler: keyHandlerMock,
|
attachCustomKeyEventHandler: keyHandlerMock,
|
||||||
focus: focusMock,
|
focus: focusMock,
|
||||||
paste: pasteMock,
|
paste: pasteMock,
|
||||||
|
input: inputMock,
|
||||||
|
textarea: (textarea = document.createElement("textarea")),
|
||||||
|
// xterm's own element, holding its textarea (lib/imeInput listens on it).
|
||||||
|
element: document.createElement("div").appendChild(textarea).parentElement,
|
||||||
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
@@ -503,4 +511,100 @@ 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();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-image.png ")
|
||||||
|
);
|
||||||
|
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
||||||
|
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
||||||
|
// pasted like any browser paste: sending ^V for it pasted nothing.
|
||||||
|
it("lets Ctrl+V reach the browser's paste: text is pasted, ^V only for an empty clipboard", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
|
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
|
||||||
|
// Not cancelled: xterm's own paste listener takes the text.
|
||||||
|
expect(
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [], getData: () => "some text" },
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(inputMock).not.toHaveBeenCalled();
|
||||||
|
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [], getData: () => "" },
|
||||||
|
});
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(inputMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
|
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(inputMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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", () => {
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const type = (value: string) => {
|
||||||
|
textarea.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertReplacementText" }));
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.dispatchEvent(new InputEvent("input", { inputType: "insertReplacementText" }));
|
||||||
|
};
|
||||||
|
textarea.value = "x"; // xterm itself sent this one (insertText)
|
||||||
|
type("xi");
|
||||||
|
type("xin");
|
||||||
|
textarea.value = "xin vie";
|
||||||
|
type("xin viet");
|
||||||
|
type("xin viêt");
|
||||||
|
type("xin việt");
|
||||||
|
expect(inputMock.mock.calls).toEqual([
|
||||||
|
["i", true],
|
||||||
|
["n", true],
|
||||||
|
["t", true],
|
||||||
|
["\x7f\x7fêt", true],
|
||||||
|
["\x7f\x7fệt", true],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* @file AgentInstructionsSettings — Settings → AI tools → My agent
|
||||||
|
* instructions (issue #67): the account's own rules in markdown, put before
|
||||||
|
* every workspace's own instructions in each new Claude or Codex session of a
|
||||||
|
* workspace it owns (`GET/PUT /api/me/instructions`). Saved per account. A
|
||||||
|
* failed load says so with a Retry instead of leaving a dead, empty editor —
|
||||||
|
* the editor stays read-only until the saved text is known, so a save can
|
||||||
|
* never overwrite instructions it did not load. **Choose file** reads a local
|
||||||
|
* Markdown/text file in the browser into the draft (nothing is uploaded until
|
||||||
|
* Save, and Discard brings the saved text back). "Applies to" lists only the
|
||||||
|
* installed, enabled tools whose panes the server says receive them.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||||
|
import { FileUp } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type AgentInstructionsView } from "../../lib/api";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function AgentInstructionsSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [saved, setSaved] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [max, setMax] = useState<number | undefined>();
|
||||||
|
const [toolIds, setToolIds] = useState<string[]>([]);
|
||||||
|
const { tools } = useAiTools();
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const apply = (r: AgentInstructionsView) => {
|
||||||
|
setSaved(r.instructions);
|
||||||
|
setDraft(r.instructions);
|
||||||
|
setMax(r.max);
|
||||||
|
setToolIds(r.tools ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoadError(null);
|
||||||
|
api.me
|
||||||
|
.instructions()
|
||||||
|
.then(apply)
|
||||||
|
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
apply(await api.me.saveInstructions(draft));
|
||||||
|
toast.success(t("agentInstructions.saved"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
t("agentInstructions.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFile(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = ""; // the same file can be picked again after a Discard
|
||||||
|
if (!file) return;
|
||||||
|
const text = await file.text();
|
||||||
|
if (max && text.length > max) {
|
||||||
|
toast.error(
|
||||||
|
t("agentInstructions.fileTooBig", {
|
||||||
|
name: file.name,
|
||||||
|
size: text.length.toLocaleString(),
|
||||||
|
max: max.toLocaleString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliesTo = tools.filter((x) => x.installed && x.enabled && toolIds.includes(x.id));
|
||||||
|
const ready = saved !== null;
|
||||||
|
const dirty = ready && draft !== saved;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="agent-instructions"
|
||||||
|
title={t("agentInstructions.title")}
|
||||||
|
description={t("agentInstructions.description")}
|
||||||
|
>
|
||||||
|
<div className="pt-1">
|
||||||
|
{appliesTo.length > 0 && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
|
||||||
|
<span>{t("agentInstructions.appliesTo")}</span>
|
||||||
|
{appliesTo.map((x) => (
|
||||||
|
<span
|
||||||
|
key={x.id}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2 py-0.5 pl-1 pr-2.5 text-fg-secondary"
|
||||||
|
>
|
||||||
|
<AiToolIcon tool={x} className="h-4 w-4" />
|
||||||
|
{x.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
aria-label={t("agentInstructions.title")}
|
||||||
|
className="block w-full min-h-[180px] resize-y rounded-lg border border-border bg-surface-1 px-3 py-2.5 font-mono text-xs leading-relaxed text-fg-primary placeholder:text-fg-muted/70 focus:border-accent focus:outline-none disabled:opacity-60"
|
||||||
|
rows={8}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={t("agentInstructions.placeholder")}
|
||||||
|
value={draft}
|
||||||
|
maxLength={max}
|
||||||
|
disabled={!ready}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
|
{loadError ? (
|
||||||
|
<p className="flex min-w-0 items-center gap-2 text-xs text-status-danger">
|
||||||
|
<span className="truncate">
|
||||||
|
{t("agentInstructions.loadFailed", { message: loadError })}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
|
||||||
|
{t("common:retry")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] tabular-nums text-fg-muted">
|
||||||
|
{ready && max ? `${draft.length.toLocaleString()} / ${max.toLocaleString()}` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept=".md,.markdown,.txt,text/markdown,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
data-testid="agent-instructions-file"
|
||||||
|
onChange={(e) => void pickFile(e)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
|
||||||
|
disabled={!ready || saving}
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
>
|
||||||
|
<FileUp className="h-3.5 w-3.5" />
|
||||||
|
{t("agentInstructions.chooseFile")}
|
||||||
|
</button>
|
||||||
|
{dirty && !saving && (
|
||||||
|
<button type="button" className="btn-ghost text-xs" onClick={() => setDraft(saved)}>
|
||||||
|
{t("agentInstructions.discard")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={saving || !dirty}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file AppearanceSettings.tsx
|
* @file AppearanceSettings.tsx
|
||||||
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
* @description Settings → Appearance (`/settings/appearance`), grouped so
|
||||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
|
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||||
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||||
* `#workspace-tabs`), the interface language, the
|
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
* `#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
|
* 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
|
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||||
@@ -34,6 +37,15 @@ import {
|
|||||||
type TerminalFont,
|
type TerminalFont,
|
||||||
} from "../../lib/terminalFont";
|
} from "../../lib/terminalFont";
|
||||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
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> = {
|
export const THEME_LABELS: Record<string, string> = {
|
||||||
darcula: "Darcula",
|
darcula: "Darcula",
|
||||||
@@ -82,6 +94,8 @@ export function AppearanceSettings() {
|
|||||||
const { t, i18n } = useTranslation("settings");
|
const { t, i18n } = useTranslation("settings");
|
||||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||||
|
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||||
|
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||||
const pickTermFont = (font: TerminalFont) => {
|
const pickTermFont = (font: TerminalFont) => {
|
||||||
writeTerminalFont(font);
|
writeTerminalFont(font);
|
||||||
@@ -103,31 +117,11 @@ export function AppearanceSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ChatUiSettings />
|
<SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
|
||||||
|
|
||||||
<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")}
|
|
||||||
>
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
|
id="language"
|
||||||
label={t("language.label")}
|
label={t("language.label")}
|
||||||
|
description={t("language.description")}
|
||||||
control={
|
control={
|
||||||
<SettingsSegmented
|
<SettingsSegmented
|
||||||
ariaLabel={t("language.label")}
|
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
|
<SettingsRow
|
||||||
label={t("terminalFont.label")}
|
id="text-size"
|
||||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
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={
|
control={
|
||||||
// A dropdown: five font names overflow a segmented row.
|
// A dropdown: five font names overflow a segmented row.
|
||||||
<Select<TerminalFont>
|
<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
|
<p
|
||||||
data-testid="terminal-font-preview"
|
data-testid="terminal-font-preview"
|
||||||
aria-label={t("terminalFont.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.
|
// 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|
|
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||||
</p>
|
</p>
|
||||||
</SettingsSection>
|
</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">
|
<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>
|
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
|
* @file ChatUiSettings — Settings → Appearance → Sessions, the Chat UI row
|
||||||
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
|
* (`#chat-ui`): whether a Claude or Codex session opens as a chat like Claude
|
||||||
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
|
* 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.
|
* account on a shared dashboard keeps its own choice on any browser.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -9,25 +10,22 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { saveChatUi, useChatUi } from "../../lib/chatUi";
|
import { saveChatUi, useChatUi } from "../../lib/chatUi";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
import { SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
export function ChatUiSettings() {
|
export function ChatUiSettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const chatUi = useChatUi();
|
const chatUi = useChatUi();
|
||||||
return (
|
return (
|
||||||
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
|
<SettingsSwitchRow
|
||||||
<SettingsSwitchRow
|
id="chat-ui"
|
||||||
label={t("chatUi.label")}
|
label={t("chatUi.label")}
|
||||||
description={t("chatUi.labelDesc")}
|
description={t("chatUi.labelDesc")}
|
||||||
checked={chatUi}
|
checked={chatUi}
|
||||||
onChange={(on) =>
|
onChange={(on) =>
|
||||||
saveChatUi(on).catch((err: unknown) =>
|
saveChatUi(on).catch((err: unknown) =>
|
||||||
toast.error(
|
toast.error(t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }))
|
||||||
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
|
)
|
||||||
)
|
}
|
||||||
)
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
if (
|
||||||
|
id?.ssh &&
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.replaceConfirm"),
|
||||||
|
confirmLabel: t("gitIdentity.replace"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(() => api.gitIdentity.generateKey(Boolean(id?.ssh)), t("gitIdentity.keyMade"));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
|
||||||
|
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
|
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{id.ssh ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
aria-label={t("gitIdentity.publicKey")}
|
||||||
|
value={id.ssh.publicKey}
|
||||||
|
rows={3}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||||
|
{id.ssh.fingerprint}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.copy")}
|
||||||
|
</button>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void removeKey()}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { Checkbox } from "../Checkbox";
|
|||||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
@@ -208,6 +209,7 @@ export type PricingVendor = "claude" | "openai" | "google";
|
|||||||
*/
|
*/
|
||||||
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
const href = useSettingsHref();
|
||||||
return (
|
return (
|
||||||
<div className="card flex items-center justify-between gap-4 p-5">
|
<div className="card flex items-center justify-between gap-4 p-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -217,7 +219,7 @@ export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
|
<Link to={href(`pricing#pricing-${vendor}`)} className="btn-ghost shrink-0 text-xs">
|
||||||
{t("pricingLink.open")}
|
{t("pricingLink.open")}
|
||||||
<ArrowRight className="h-3.5 w-3.5" />
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -37,15 +37,19 @@ export function SettingsRow({
|
|||||||
description,
|
description,
|
||||||
control,
|
control,
|
||||||
alignTop = false,
|
alignTop = false,
|
||||||
|
id,
|
||||||
}: {
|
}: {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
control: ReactNode;
|
control: ReactNode;
|
||||||
alignTop?: boolean;
|
alignTop?: boolean;
|
||||||
|
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
||||||
|
id?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
description ? "py-3" : "py-2"
|
||||||
} ${alignTop ? "items-start" : "items-center"}`}
|
} ${alignTop ? "items-start" : "items-center"}`}
|
||||||
>
|
>
|
||||||
@@ -64,15 +68,18 @@ export function SettingsSwitchRow({
|
|||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
id,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (v: boolean) => void;
|
onChange: (v: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
id?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
|
id={id}
|
||||||
label={label}
|
label={label}
|
||||||
description={description}
|
description={description}
|
||||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @file AgentInstructionsSettings.test.tsx
|
||||||
|
* @description My agent instructions: a failed load shows the error with a
|
||||||
|
* Retry (the editor stays locked, so a save cannot overwrite unloaded text)
|
||||||
|
* and Retry unlocks it; Choose file puts a local file's text into the draft
|
||||||
|
* without saving, and refuses one longer than the limit; "Applies to" names
|
||||||
|
* only the installed, enabled tools the server says receive them.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AgentInstructionsSettings } from "../AgentInstructionsSettings";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import { toast } from "../../../lib/toast";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { me: { instructions: vi.fn(), saveInstructions: vi.fn() } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const aiTools = vi.hoisted(() => ({
|
||||||
|
tools: [] as { id: string; label: string; installed: boolean; enabled: boolean }[],
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
||||||
|
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
const instructions = vi.mocked(api.me.instructions);
|
||||||
|
const saveInstructions = vi.mocked(api.me.saveInstructions);
|
||||||
|
const editor = () => screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||||
|
// jsdom's File has no text(); every browser AgentHub supports does.
|
||||||
|
const pick = (text: string, name = "rules.md") => {
|
||||||
|
const file = Object.assign(new File([text], name, { type: "text/markdown" }), {
|
||||||
|
text: () => Promise.resolve(text),
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId("agent-instructions-file"), { target: { files: [file] } });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AgentInstructionsSettings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
aiTools.tools = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names only the installed, enabled tools that receive the instructions", async () => {
|
||||||
|
aiTools.tools = [
|
||||||
|
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
||||||
|
{ id: "codex", label: "Codex", installed: true, enabled: false },
|
||||||
|
{ id: "cursor", label: "Cursor CLI", installed: true, enabled: true },
|
||||||
|
];
|
||||||
|
instructions.mockResolvedValue({ instructions: "", max: 100, tools: ["claude", "codex"] });
|
||||||
|
render(<AgentInstructionsSettings />);
|
||||||
|
expect(await screen.findByText("Claude Code")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Applies to")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Codex")).toBeNull();
|
||||||
|
expect(screen.queryByText("Cursor CLI")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a failed load with Retry, and Retry unlocks the editor", async () => {
|
||||||
|
instructions.mockRejectedValueOnce(new Error("HTTP 404"));
|
||||||
|
render(<AgentInstructionsSettings />);
|
||||||
|
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
|
||||||
|
expect(editor()).toBeDisabled();
|
||||||
|
|
||||||
|
instructions.mockResolvedValueOnce({ instructions: "# saved", max: 100, tools: [] });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||||
|
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||||
|
expect(editor().value).toBe("# saved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Choose file fills the draft without saving; Save sends it", async () => {
|
||||||
|
instructions.mockResolvedValue({ instructions: "", max: 100, tools: [] });
|
||||||
|
saveInstructions.mockResolvedValue({ instructions: "# from file", max: 100, tools: [] });
|
||||||
|
render(<AgentInstructionsSettings />);
|
||||||
|
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||||
|
|
||||||
|
pick("# from file");
|
||||||
|
await waitFor(() => expect(editor().value).toBe("# from file"));
|
||||||
|
expect(saveInstructions).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
await waitFor(() => expect(saveInstructions).toHaveBeenCalledWith("# from file"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a file longer than the limit and keeps the draft", async () => {
|
||||||
|
instructions.mockResolvedValue({ instructions: "keep", max: 5, tools: [] });
|
||||||
|
render(<AgentInstructionsSettings />);
|
||||||
|
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||||
|
|
||||||
|
pick("way too long");
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||||
|
expect(editor().value).toBe("keep");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file AppearanceSettings.test.tsx
|
* @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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ describe("AppearanceSettings", () => {
|
|||||||
root.classList.remove("dark");
|
root.classList.remove("dark");
|
||||||
root.removeAttribute("data-theme");
|
root.removeAttribute("data-theme");
|
||||||
root.removeAttribute("data-mode");
|
root.removeAttribute("data-mode");
|
||||||
|
root.style.removeProperty("font-size");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches the interface language and holds the pet block", async () => {
|
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(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
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,113 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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,512 @@
|
|||||||
|
/**
|
||||||
|
* @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 { 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
{!changed && (
|
||||||
|
<span className="mr-auto text-xs text-fg-muted">{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,133 @@
|
|||||||
|
/**
|
||||||
|
* @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() }));
|
||||||
|
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("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;
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"addLaneToolDefaultBadge": "Default",
|
"addLaneToolDefaultBadge": "Default",
|
||||||
"addLaneNoTools": "No AI tool is installed and enabled.",
|
"addLaneNoTools": "No AI tool is installed and enabled.",
|
||||||
"addLaneNoToolsLink": "Open Settings → AI Tools",
|
"addLaneNoToolsLink": "Open Settings → AI Tools",
|
||||||
"addLaneToolHint": "Which CLI drives this workspace. Cannot be changed later.",
|
"addLaneToolHint": "Default AI tool",
|
||||||
"addLaneWorktreeDetached": "(detached)",
|
"addLaneWorktreeDetached": "(detached)",
|
||||||
"addLaneWorktreesMore": "+{{count}} more",
|
"addLaneWorktreesMore": "+{{count}} more",
|
||||||
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
|
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
|
||||||
@@ -42,8 +42,27 @@
|
|||||||
"browse.empty": "This folder is empty.",
|
"browse.empty": "This folder is empty.",
|
||||||
"browse.fileTitle": "Browse for a file",
|
"browse.fileTitle": "Browse for a file",
|
||||||
"browse.select": "Select this folder",
|
"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",
|
"browse.title": "Browse for a folder",
|
||||||
"browse.typePath": "No native picker is available here — type the path instead.",
|
|
||||||
"changes.back": "All files",
|
"changes.back": "All files",
|
||||||
"changes.bin": "bin",
|
"changes.bin": "bin",
|
||||||
"changes.binary": "Binary file — not shown.",
|
"changes.binary": "Binary file — not shown.",
|
||||||
@@ -65,6 +84,12 @@
|
|||||||
"changes.expand": "Show the changes panel",
|
"changes.expand": "Show the changes panel",
|
||||||
"changes.expandAll": "Expand every file",
|
"changes.expandAll": "Expand every file",
|
||||||
"changes.expandAllFolders": "Expand all folders",
|
"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.expandContext": "Show more surrounding lines",
|
||||||
"changes.expandFile": "Expand {{path}}",
|
"changes.expandFile": "Expand {{path}}",
|
||||||
"changes.fileGone": "That file no longer has changes in this range.",
|
"changes.fileGone": "That file no longer has changes in this range.",
|
||||||
@@ -167,6 +192,7 @@
|
|||||||
"gitBar.pull": "Pull",
|
"gitBar.pull": "Pull",
|
||||||
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
||||||
"gitBar.push": "Push",
|
"gitBar.push": "Push",
|
||||||
|
"gitBar.nothingToPush": "Nothing to push — {{upstream}} already has every commit.",
|
||||||
"gitBar.pushHint": "git push -u origin HEAD",
|
"gitBar.pushHint": "git push -u origin HEAD",
|
||||||
"gitBar.switch": "Branch",
|
"gitBar.switch": "Branch",
|
||||||
"gitBar.switchApply": "Switch branch",
|
"gitBar.switchApply": "Switch branch",
|
||||||
@@ -181,6 +207,21 @@
|
|||||||
"mode.repoHint": "Adopt this directory as a workspace, as-is — no worktree, no new branch.",
|
"mode.repoHint": "Adopt this directory as a workspace, as-is — no worktree, no new branch.",
|
||||||
"mode.worktree": "Worktree",
|
"mode.worktree": "Worktree",
|
||||||
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
|
"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",
|
"moreActions": "More actions",
|
||||||
"pipelinePicker.label": "Pipeline template",
|
"pipelinePicker.label": "Pipeline template",
|
||||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||||
@@ -249,6 +290,7 @@
|
|||||||
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
|
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
|
||||||
"changes.discardShortConfirm": "Discard?",
|
"changes.discardShortConfirm": "Discard?",
|
||||||
"changes.amend": "Amend",
|
"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.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.amendPlaceholder": "New message (empty = keep the old one)",
|
||||||
"changes.historyOf": "History of branch",
|
"changes.historyOf": "History of branch",
|
||||||
@@ -325,6 +367,9 @@
|
|||||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||||
"tree.renameSession": "Rename session",
|
"tree.renameSession": "Rename session",
|
||||||
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
"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.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}",
|
||||||
"tree.archiveWorktreeTitle": "Archive and remove its worktree?",
|
"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.",
|
"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.",
|
||||||
@@ -384,6 +429,8 @@
|
|||||||
"tabs.close": "Close tab (stops its session)",
|
"tabs.close": "Close tab (stops its session)",
|
||||||
"tabs.full": "All 10 sessions are in use — stop one first",
|
"tabs.full": "All 10 sessions are in use — stop one first",
|
||||||
"tabs.agentSettings": "Agent settings…",
|
"tabs.agentSettings": "Agent settings…",
|
||||||
|
"tabs.settings": "Settings",
|
||||||
|
"tabs.closeSettings": "Close Settings",
|
||||||
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
|
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
|
||||||
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
|
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
|
||||||
"panes.pick": "Show another session in this pane",
|
"panes.pick": "Show another session in this pane",
|
||||||
@@ -402,6 +449,8 @@
|
|||||||
"sessionMenu.details": "Session details",
|
"sessionMenu.details": "Session details",
|
||||||
"sessionMenu.copyId": "Copy session ID",
|
"sessionMenu.copyId": "Copy session ID",
|
||||||
"sessionMenu.archive": "Archive",
|
"sessionMenu.archive": "Archive",
|
||||||
|
"sessionMenu.pin": "Pin",
|
||||||
|
"sessionMenu.unpin": "Unpin",
|
||||||
"panes.show": "Show {{name}} here",
|
"panes.show": "Show {{name}} here",
|
||||||
"panes.splitNew.right": "Split right with a new terminal",
|
"panes.splitNew.right": "Split right with a new terminal",
|
||||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||||
@@ -473,5 +522,89 @@
|
|||||||
"changes.historyWholeBranch": "Whole branch",
|
"changes.historyWholeBranch": "Whole branch",
|
||||||
"changes.annotate": "Annotate",
|
"changes.annotate": "Annotate",
|
||||||
"changes.noBlame": "Nothing to annotate: this file has no committed history yet",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,5 +85,6 @@
|
|||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"sidebar": "Sidebar",
|
"sidebar": "Sidebar",
|
||||||
"now": "now",
|
"now": "now",
|
||||||
"users": "Users"
|
"users": "Users",
|
||||||
|
"files": "Files"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"hint": "Drop files or images to attach",
|
"hint": "Drop files or images to attach",
|
||||||
|
"paste": "Drop to paste into the terminal",
|
||||||
"uploading": "Uploading…",
|
"uploading": "Uploading…",
|
||||||
"failed": "Could not attach the file",
|
"failed": "Could not attach the file",
|
||||||
"remove": "Remove attachment",
|
"remove": "Remove attachment",
|
||||||
@@ -86,6 +87,9 @@
|
|||||||
"placeholder": "Write a message…",
|
"placeholder": "Write a message…",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"notSent": "The terminal is not connected — the message was not sent.",
|
"notSent": "The terminal is not connected — the message was not sent.",
|
||||||
|
"notStarted": "Claude Code may be asking something in the terminal — whether to trust this folder, for example. Sending now presses Enter there, which can answer it (its preselected choice is \"No, exit\"). Open the terminal to answer it first.",
|
||||||
|
"notStartedTitle": "The session has not started",
|
||||||
|
"sendAnyway": "Send anyway",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"notStopped": "The terminal is not connected — nothing was stopped.",
|
"notStopped": "The terminal is not connected — nothing was stopped.",
|
||||||
"add": "Add files, skills…",
|
"add": "Add files, skills…",
|
||||||
@@ -119,6 +123,15 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"noPreview": "No preview for this file here",
|
"noPreview": "No preview for this file here",
|
||||||
"truncated": "Showing the first part of this file"
|
"truncated": "Showing the first part of this file"
|
||||||
|
},
|
||||||
|
"contextTitle": "Context: {{used}} of {{size}} tokens — Claude Code compacts it when it is nearly full",
|
||||||
|
"tab": {
|
||||||
|
"waiting": "Waiting",
|
||||||
|
"done": "Done"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"placeholder": "Type your answer…",
|
||||||
|
"cancel": "Cancel (Esc)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interrupted": {
|
"interrupted": {
|
||||||
|
|||||||
@@ -143,6 +143,8 @@
|
|||||||
"loading": "Loading conversation…",
|
"loading": "Loading conversation…",
|
||||||
"empty": "No conversation records found.",
|
"empty": "No conversation records found.",
|
||||||
"emptyBody": "This session's metadata was imported, but its transcript file is no longer on disk. Claude Code automatically deletes inactive session transcripts after a retention period (cleanupPeriodDays, default 30 days), so older conversations may already be gone. Sessions imported from now on are snapshotted and kept even after Claude Code prunes the originals.",
|
"emptyBody": "This session's metadata was imported, but its transcript file is no longer on disk. Claude Code automatically deletes inactive session transcripts after a retention period (cleanupPeriodDays, default 30 days), so older conversations may already be gone. Sessions imported from now on are snapshotted and kept even after Claude Code prunes the originals.",
|
||||||
|
"notStarted": "The session has not started yet.",
|
||||||
|
"notStartedBody": "Claude Code may be asking you something in the terminal first — whether to trust this folder, for example. Answer it there; the conversation shows up here once the session starts.",
|
||||||
"newMessages": "New messages",
|
"newMessages": "New messages",
|
||||||
"systemReminder": "System reminder",
|
"systemReminder": "System reminder",
|
||||||
"persistedOutput": "Persisted output",
|
"persistedOutput": "Persisted output",
|
||||||
@@ -199,7 +201,16 @@
|
|||||||
},
|
},
|
||||||
"edited_one": "Edited {{count}} file",
|
"edited_one": "Edited {{count}} file",
|
||||||
"edited_other": "Edited {{count}} files",
|
"edited_other": "Edited {{count}} files",
|
||||||
"working": "Thinking…"
|
"working": "Thinking…",
|
||||||
|
"turnTokens": "↑ {{tokens}} tokens",
|
||||||
|
"waiting": "Waiting for you in the terminal",
|
||||||
|
"waitingFor": "Waiting for your approval: {{step}}",
|
||||||
|
"openTerminal": "Open terminal",
|
||||||
|
"moreSteps_one": "+{{count}} more",
|
||||||
|
"moreSteps_other": "+{{count}} more",
|
||||||
|
"quiet": "no new activity for {{time}}",
|
||||||
|
"askDeclined": "Declined — no answer given",
|
||||||
|
"turnDone": "Done in {{time}}"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -37,29 +37,199 @@
|
|||||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
|
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
|
||||||
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
||||||
"panel": {
|
"panel": {
|
||||||
"label": "Sagi companion",
|
"label": "{{name}} companion",
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"close": "Close Sagi",
|
"close": "Close {{name}}",
|
||||||
"statLive": "live",
|
"statLive": "live",
|
||||||
"statWaiting": "waiting",
|
"statWaiting": "waiting",
|
||||||
"statErrored": "errored",
|
"statErrored": "errored",
|
||||||
"runClaude": "Run Claude",
|
|
||||||
"activity": "Activity",
|
|
||||||
"sessions": "Sessions",
|
|
||||||
"errored": "Errored",
|
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"unmute": "Unmute",
|
"unmute": "Unmute",
|
||||||
"clearAlerts": "Clear alerts",
|
"clearAlerts": "Clear alerts",
|
||||||
"askPlaceholder": "Ask Sagi… (e.g. any errors?)",
|
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
|
||||||
"ask": "Ask Sagi",
|
"ask": "Ask {{name}}",
|
||||||
"send": "Send"
|
"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",
|
||||||
|
"chat": "Chat with {{name}}"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Dismiss",
|
"bubbleDismiss": "Dismiss",
|
||||||
"open": "Open Sagi companion",
|
"open": "Open {{name}} companion",
|
||||||
"closeAvatar": "Close Sagi",
|
"closeAvatar": "Close {{name}}",
|
||||||
"avatarTitle": "Sagi — Ctrl/⌘+B · drag or arrow keys to move · right-click for pets",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||||
"startFailed": "Couldn't start that session: {{error}}"
|
"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": {
|
"pricing": {
|
||||||
"vendor": {
|
"vendor": {
|
||||||
@@ -626,6 +796,7 @@
|
|||||||
"title": "AI Proxy",
|
"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.",
|
"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",
|
"add": "Add profile",
|
||||||
|
"config9router": "Config 9router",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||||
@@ -722,6 +893,30 @@
|
|||||||
"pet": "Pet",
|
"pet": "Pet",
|
||||||
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
"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). 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."
|
||||||
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
"description": "One login per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
|
"description": "One login per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
|
||||||
@@ -820,6 +1015,10 @@
|
|||||||
"data": "Data"
|
"data": "Data"
|
||||||
},
|
},
|
||||||
"section": {
|
"section": {
|
||||||
|
"account": {
|
||||||
|
"title": "Account",
|
||||||
|
"description": "Your profile, password and sign-ins on this server."
|
||||||
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"description": "Language, colour mode and theme, and the corner pet."
|
"description": "Language, colour mode and theme, and the corner pet."
|
||||||
@@ -886,6 +1085,14 @@
|
|||||||
"agenthub": "AgentHub-managed worktrees",
|
"agenthub": "AgentHub-managed worktrees",
|
||||||
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
|
"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": {
|
"language": {
|
||||||
"title": "Language",
|
"title": "Language",
|
||||||
"description": "The language of the whole dashboard, saved to your account.",
|
"description": "The language of the whole dashboard, saved to your account.",
|
||||||
@@ -901,9 +1108,20 @@
|
|||||||
"label": "Tabs from every workspace",
|
"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."
|
"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": {
|
"terminalFont": {
|
||||||
"title": "Terminal font",
|
"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",
|
"label": "Font",
|
||||||
"names": {
|
"names": {
|
||||||
"jetbrains": "JetBrains Mono",
|
"jetbrains": "JetBrains Mono",
|
||||||
@@ -911,7 +1129,8 @@
|
|||||||
"system": "System font"
|
"system": "System font"
|
||||||
},
|
},
|
||||||
"systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.",
|
"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": {
|
"rowMenu": {
|
||||||
"copyHost": "Copy host",
|
"copyHost": "Copy host",
|
||||||
@@ -1032,5 +1251,45 @@
|
|||||||
"label": "Open new sessions as a chat",
|
"label": "Open new sessions as a chat",
|
||||||
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
||||||
"saveFailed": "Could not save: {{message}}"
|
"saveFailed": "Could not save: {{message}}"
|
||||||
|
},
|
||||||
|
"agentInstructions": {
|
||||||
|
"title": "My agent instructions",
|
||||||
|
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
|
||||||
|
"placeholder": "# How I work\n- Answer in Vietnamese\n- Keep diffs small; run the tests before finishing",
|
||||||
|
"saved": "Instructions saved — new sessions will use them",
|
||||||
|
"saveFailed": "Could not save: {{message}}",
|
||||||
|
"loadFailed": "Could not load your instructions: {{message}}",
|
||||||
|
"discard": "Discard",
|
||||||
|
"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",
|
"username": "Username",
|
||||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordHint": "At least 8 characters",
|
"passwordHint": "No minimum length",
|
||||||
"newPassword": "New password",
|
"newPassword": "New password",
|
||||||
"changeRole": "Change role",
|
"changeRole": "Change role",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
@@ -56,7 +56,8 @@
|
|||||||
"disabled": "{{name}} disabled",
|
"disabled": "{{name}} disabled",
|
||||||
"enabled": "{{name}} enabled",
|
"enabled": "{{name}} enabled",
|
||||||
"password": "{{name}}'s password reset",
|
"password": "{{name}}'s password reset",
|
||||||
"workspace": "Workspace given to {{name}}"
|
"workspace": "Workspace given to {{name}}",
|
||||||
|
"updated": "{{name}} updated"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"EREAUTH": "Your password is wrong.",
|
"EREAUTH": "Your password is wrong.",
|
||||||
@@ -64,12 +65,127 @@
|
|||||||
"ESELF": "You cannot change your own role or disable yourself.",
|
"ESELF": "You cannot change your own role or disable yourself.",
|
||||||
"EEXISTS": "That username is taken.",
|
"EEXISTS": "That username is taken.",
|
||||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
"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."
|
||||||
},
|
},
|
||||||
"readOnly": "Analysts can view accounts but not change them.",
|
"readOnly": "Analysts can view accounts but not change them.",
|
||||||
"auditTitle": "Audit log",
|
"auditTitle": "Audit log",
|
||||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
||||||
"auditEmpty": "Nothing recorded yet.",
|
"auditEmpty": "Nothing recorded yet.",
|
||||||
"activeCount": "{{n}} active",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"addLaneToolDefaultBadge": "Mặc định",
|
"addLaneToolDefaultBadge": "Mặc định",
|
||||||
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
|
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
|
||||||
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
|
"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.",
|
"addLaneToolHint": "AI tool mặc định",
|
||||||
"addLaneWorktreeDetached": "(detached)",
|
"addLaneWorktreeDetached": "(detached)",
|
||||||
"addLaneWorktreesMore": "+{{count}} nữa",
|
"addLaneWorktreesMore": "+{{count}} nữa",
|
||||||
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
|
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
|
||||||
@@ -42,8 +42,27 @@
|
|||||||
"browse.empty": "Thư mục này trống.",
|
"browse.empty": "Thư mục này trống.",
|
||||||
"browse.fileTitle": "Duyệt chọn tệp",
|
"browse.fileTitle": "Duyệt chọn tệp",
|
||||||
"browse.select": "Chọn thư mục này",
|
"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",
|
"browse.title": "Duyệt chọn thư mục",
|
||||||
"browse.typePath": "Không mở được hộp thoại chọn của hệ điều hành ở đây — hãy gõ đường dẫn.",
|
|
||||||
"changes.back": "Tất cả file",
|
"changes.back": "Tất cả file",
|
||||||
"changes.bin": "bin",
|
"changes.bin": "bin",
|
||||||
"changes.binary": "File nhị phân — không hiển thị.",
|
"changes.binary": "File nhị phân — không hiển thị.",
|
||||||
@@ -65,6 +84,12 @@
|
|||||||
"changes.expand": "Mở bảng thay đổi",
|
"changes.expand": "Mở bảng thay đổi",
|
||||||
"changes.expandAll": "Mở tất cả file",
|
"changes.expandAll": "Mở tất cả file",
|
||||||
"changes.expandAllFolders": "Mở tất cả thư mục",
|
"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.expandContext": "Hiện thêm dòng xung quanh",
|
||||||
"changes.expandFile": "Mở {{path}}",
|
"changes.expandFile": "Mở {{path}}",
|
||||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||||
@@ -167,6 +192,7 @@
|
|||||||
"gitBar.pull": "Pull",
|
"gitBar.pull": "Pull",
|
||||||
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
||||||
"gitBar.push": "Push",
|
"gitBar.push": "Push",
|
||||||
|
"gitBar.nothingToPush": "Không có gì để push — {{upstream}} đã có mọi commit.",
|
||||||
"gitBar.pushHint": "git push -u origin HEAD",
|
"gitBar.pushHint": "git push -u origin HEAD",
|
||||||
"gitBar.switch": "Nhánh",
|
"gitBar.switch": "Nhánh",
|
||||||
"gitBar.switchApply": "Chuyển nhánh",
|
"gitBar.switchApply": "Chuyển nhánh",
|
||||||
@@ -181,6 +207,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.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.worktree": "Worktree",
|
||||||
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
|
"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",
|
"moreActions": "Thêm hành động",
|
||||||
"pipelinePicker.label": "Mẫu pipeline",
|
"pipelinePicker.label": "Mẫu pipeline",
|
||||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||||
@@ -249,6 +290,7 @@
|
|||||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
|
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
|
||||||
"changes.discardShortConfirm": "Xoá?",
|
"changes.discardShortConfirm": "Xoá?",
|
||||||
"changes.amend": "Amend",
|
"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.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.amendPlaceholder": "Message mới (trống = giữ message cũ)",
|
||||||
"changes.historyOf": "Lịch sử của nhánh",
|
"changes.historyOf": "Lịch sử của nhánh",
|
||||||
@@ -325,6 +367,9 @@
|
|||||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||||
"tree.renameSession": "Đổi tên session",
|
"tree.renameSession": "Đổi tên session",
|
||||||
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
|
"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.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}",
|
||||||
"tree.archiveWorktreeTitle": "Lưu trữ và gỡ worktree của session?",
|
"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.",
|
"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.",
|
||||||
@@ -384,6 +429,8 @@
|
|||||||
"tabs.close": "Đóng tab (dừng phiên của nó)",
|
"tabs.close": "Đóng tab (dừng phiên của nó)",
|
||||||
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
|
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
|
||||||
"tabs.agentSettings": "Cài đặt agent…",
|
"tabs.agentSettings": "Cài đặt agent…",
|
||||||
|
"tabs.settings": "Cài đặt",
|
||||||
|
"tabs.closeSettings": "Đóng Cài đặt",
|
||||||
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
|
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
|
||||||
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
|
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
|
||||||
"panes.pick": "Hiện session khác trong pane này",
|
"panes.pick": "Hiện session khác trong pane này",
|
||||||
@@ -402,6 +449,8 @@
|
|||||||
"sessionMenu.details": "Chi tiết session",
|
"sessionMenu.details": "Chi tiết session",
|
||||||
"sessionMenu.copyId": "Sao chép ID session",
|
"sessionMenu.copyId": "Sao chép ID session",
|
||||||
"sessionMenu.archive": "Lưu trữ",
|
"sessionMenu.archive": "Lưu trữ",
|
||||||
|
"sessionMenu.pin": "Ghim",
|
||||||
|
"sessionMenu.unpin": "Bỏ ghim",
|
||||||
"panes.show": "Hiện {{name}} ở đây",
|
"panes.show": "Hiện {{name}} ở đây",
|
||||||
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||||
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||||
@@ -473,5 +522,89 @@
|
|||||||
"changes.historyWholeBranch": "Cả nhánh",
|
"changes.historyWholeBranch": "Cả nhánh",
|
||||||
"changes.annotate": "Annotate",
|
"changes.annotate": "Annotate",
|
||||||
"changes.noBlame": "Không có gì để annotate: file này chưa có lịch sử commit",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,5 +85,6 @@
|
|||||||
"closeMenu": "Đóng menu",
|
"closeMenu": "Đóng menu",
|
||||||
"sidebar": "Thanh bên",
|
"sidebar": "Thanh bên",
|
||||||
"now": "bây giờ",
|
"now": "bây giờ",
|
||||||
"users": "Người dùng"
|
"users": "Người dùng",
|
||||||
|
"files": "Quản lý file"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"hint": "Thả file hoặc ảnh để đính kèm",
|
"hint": "Thả file hoặc ảnh để đính kèm",
|
||||||
|
"paste": "Thả để dán vào terminal",
|
||||||
"uploading": "Đang tải lên…",
|
"uploading": "Đang tải lên…",
|
||||||
"failed": "Không đính kèm được file",
|
"failed": "Không đính kèm được file",
|
||||||
"remove": "Bỏ đính kèm",
|
"remove": "Bỏ đính kèm",
|
||||||
@@ -86,6 +87,9 @@
|
|||||||
"placeholder": "Viết tin nhắn…",
|
"placeholder": "Viết tin nhắn…",
|
||||||
"send": "Gửi",
|
"send": "Gửi",
|
||||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||||
|
"notStarted": "Có thể Claude Code đang hỏi bạn trong terminal — ví dụ có tin tưởng (trust) thư mục này không. Gửi lúc này sẽ nhấn Enter ở đó và có thể trả lời câu hỏi ấy (lựa chọn mặc định là \"No, exit\"). Hãy mở terminal để trả lời trước.",
|
||||||
|
"notStartedTitle": "Phiên chưa bắt đầu",
|
||||||
|
"sendAnyway": "Vẫn gửi",
|
||||||
"stop": "Ngưng",
|
"stop": "Ngưng",
|
||||||
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
||||||
"add": "Thêm file, skill…",
|
"add": "Thêm file, skill…",
|
||||||
@@ -119,6 +123,15 @@
|
|||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
"noPreview": "Không xem trước được tệp này ở đây",
|
"noPreview": "Không xem trước được tệp này ở đây",
|
||||||
"truncated": "Chỉ hiện phần đầu của tệp"
|
"truncated": "Chỉ hiện phần đầu của tệp"
|
||||||
|
},
|
||||||
|
"contextTitle": "Ngữ cảnh: {{used}} / {{size}} token — gần đầy thì Claude Code tự compact",
|
||||||
|
"tab": {
|
||||||
|
"waiting": "Đang chờ",
|
||||||
|
"done": "Xong"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"placeholder": "Nhập câu trả lời của bạn…",
|
||||||
|
"cancel": "Huỷ (Esc)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interrupted": {
|
"interrupted": {
|
||||||
|
|||||||
@@ -143,6 +143,8 @@
|
|||||||
"loading": "Đang tải hội thoại…",
|
"loading": "Đang tải hội thoại…",
|
||||||
"empty": "Không tìm thấy bản ghi hội thoại.",
|
"empty": "Không tìm thấy bản ghi hội thoại.",
|
||||||
"emptyBody": "Siêu dữ liệu của phiên này đã được nhập, nhưng tệp transcript không còn trên đĩa. Claude Code tự động xóa transcript của phiên không hoạt động sau một thời gian lưu giữ (cleanupPeriodDays, mặc định 30 ngày), nên các hội thoại cũ có thể đã mất. Các phiên được nhập từ nay sẽ được chụp lại và giữ nguyên ngay cả khi Claude Code xóa bản gốc.",
|
"emptyBody": "Siêu dữ liệu của phiên này đã được nhập, nhưng tệp transcript không còn trên đĩa. Claude Code tự động xóa transcript của phiên không hoạt động sau một thời gian lưu giữ (cleanupPeriodDays, mặc định 30 ngày), nên các hội thoại cũ có thể đã mất. Các phiên được nhập từ nay sẽ được chụp lại và giữ nguyên ngay cả khi Claude Code xóa bản gốc.",
|
||||||
|
"notStarted": "Phiên chưa bắt đầu.",
|
||||||
|
"notStartedBody": "Có thể Claude Code đang hỏi bạn trong terminal trước — ví dụ có tin tưởng (trust) thư mục này không. Hãy trả lời ở đó; hội thoại sẽ hiện ở đây khi phiên bắt đầu.",
|
||||||
"newMessages": "Tin nhắn mới",
|
"newMessages": "Tin nhắn mới",
|
||||||
"systemReminder": "Nhắc nhở hệ thống",
|
"systemReminder": "Nhắc nhở hệ thống",
|
||||||
"persistedOutput": "Đầu ra đã lưu",
|
"persistedOutput": "Đầu ra đã lưu",
|
||||||
@@ -199,7 +201,16 @@
|
|||||||
},
|
},
|
||||||
"edited_one": "Đã sửa {{count}} tệp",
|
"edited_one": "Đã sửa {{count}} tệp",
|
||||||
"edited_other": "Đã sửa {{count}} tệp",
|
"edited_other": "Đã sửa {{count}} tệp",
|
||||||
"working": "Đang suy nghĩ…"
|
"working": "Đang suy nghĩ…",
|
||||||
|
"turnTokens": "↑ {{tokens}} token",
|
||||||
|
"waiting": "Đang chờ bạn trong terminal",
|
||||||
|
"waitingFor": "Đang chờ bạn cho phép: {{step}}",
|
||||||
|
"openTerminal": "Mở terminal",
|
||||||
|
"moreSteps_one": "+{{count}} việc nữa",
|
||||||
|
"moreSteps_other": "+{{count}} việc nữa",
|
||||||
|
"quiet": "không có hoạt động mới trong {{time}}",
|
||||||
|
"askDeclined": "Đã từ chối — không trả lời",
|
||||||
|
"turnDone": "Xong trong {{time}}"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user