Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a58e95a957 | |||
| 4d6067650a | |||
| 5bc3f7d34a | |||
| b058179177 | |||
| b6b40b0c1b | |||
| 758609775f | |||
| 09efb089cf | |||
| c83ddf62a0 | |||
| d8ab850295 | |||
| bac6c04a0b | |||
| 38c7ac6635 | |||
| 276cead826 | |||
| 1c5bd88747 | |||
| 0a1dcfeb7f | |||
| 58be04a4e7 | |||
| cd427d218b | |||
| 7a88b74016 | |||
| 8ed5018a2f | |||
| aacd63b85b | |||
| 22de5009ff | |||
| e94038481e | |||
| b441f32949 | |||
| cb19d7e5d2 | |||
| 0cb5e41faf | |||
| 1c5521cf2e | |||
| c3f83d3922 | |||
| 0c2f49876d | |||
| 9c573f1e3c | |||
| d72a255294 | |||
| b08482c6aa | |||
| 763759189f | |||
| b0dd5af22f | |||
| 7cb4f64781 | |||
| 69a2c37cd2 | |||
| b3fe10a4f3 | |||
| 8ecb96e9fc | |||
| c47dd230d4 | |||
| 46e3899945 | |||
| 5d8fc9bb98 | |||
| c8ea18f594 | |||
| 64173a5428 | |||
| 943ba03b76 | |||
| 3a8be55b55 | |||
| 2fd0c903a2 | |||
| f6f1bf4a76 |
@@ -22,7 +22,8 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
||||
"timeout": 5
|
||||
"timeout": 5,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,7 +35,8 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
||||
"timeout": 5
|
||||
"timeout": 5,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ make while you work here as Codex.
|
||||
- `scripts/` for hook/install/import/cleanup utilities.
|
||||
|
||||
## Validation expectations
|
||||
- Backend changes: run `npm run test:server` when possible.
|
||||
- Frontend changes: run `npm run test:client` when possible.
|
||||
- Run only the tests the change touches, never a full suite by default (full suites take 5–6 minutes): `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Docs-only changes run no tests.
|
||||
- Run a full suite (`npm run test:server` / `npm run test:client`) only when genuinely needed: the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib), a shared server core (`server/db.js`, `server/index.js`, auth/scope, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks. The pre-commit hook already picks related vs. full tests per commit, so do not add an extra full run before committing.
|
||||
- If any check is skipped, report it explicitly.
|
||||
|
||||
## Safety expectations
|
||||
|
||||
+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-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree`, `/:id/git/download` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/upload` (create-only, `lane-file-transfer.js`) and `POST /:id/git/:action` are same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
|
||||
@@ -385,7 +385,8 @@ graph TD
|
||||
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
|
||||
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
||||
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
||||
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
||||
| `lib/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
|
||||
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
||||
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
|
||||
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
||||
@@ -757,6 +758,7 @@ erDiagram
|
||||
TEXT metadata "JSON blob"
|
||||
TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
|
||||
TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
|
||||
TEXT pinned_at "ISO 8601 or NULL — pinned to the top of its workspace's sidebar list"
|
||||
TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
- **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"]
|
||||
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||
PANEL["SagiPanel.tsx<br/>status + quick actions + Ask box"]
|
||||
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
|
||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||
end
|
||||
|
||||
@@ -2401,16 +2403,17 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
|
||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`, which also returns the `subject` session a pulse is about — name, cwd, lane) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. An error alerts on the transition INTO `error` only, and **Clear alerts** (`clearErrors`) moves the errored sessions into `dismissed`: their later updates, agent errors and failure events stay silent until the session goes active again or ends. `attentionOf` lists errored then waiting sessions by name; `MOMENT_OF_PULSE` maps each pulse to a pet animation. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
|
||||
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
|
||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
|
||||
| **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: `Sagi.tsx` calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`quips.ts`** | Pure mood → phrase pools. |
|
||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
||||
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
|
||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. |
|
||||
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. |
|
||||
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
|
||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
|
||||
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
|
||||
|
||||
### Touchpoints Outside the Folder
|
||||
|
||||
@@ -2422,7 +2425,7 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
|
||||
| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). |
|
||||
| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. |
|
||||
| `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. |
|
||||
| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). |
|
||||
| `client/src/i18n/locales/{en,vi}/settings.json` | Every `sagi.*` string: the Settings toggle, the panel, the bubbles' quips, coalesced counts, greetings and Ask answers. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+70
-12
@@ -1284,12 +1284,14 @@ async function cmdDoctor() {
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available } = await get("/api/run/tmux");
|
||||
const { available, upgrade } = await get("/api/run/tmux");
|
||||
console.log(
|
||||
available
|
||||
? `${c.green("✔")} tmux available to the server`
|
||||
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
|
||||
!available
|
||||
? `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
|
||||
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
|
||||
: upgrade
|
||||
? `${c.yellow("!")} tmux ${upgrade}`
|
||||
: `${c.green("✔")} tmux available to the server`
|
||||
);
|
||||
} catch {
|
||||
console.log(`${c.dim("·")} tmux not reported by this server`);
|
||||
@@ -3932,21 +3934,73 @@ async function cmdServer(sub, flags) {
|
||||
} else {
|
||||
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
||||
}
|
||||
const wasServer = auth.isServerMode();
|
||||
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
||||
envNote("AGENTHUB_MODE");
|
||||
console.log(`${c.green("✔")} server mode on (${file})`);
|
||||
// One Linux user per account (S3) is the default on Linux: install it now
|
||||
// (one sudo), unless it is already in place or `--no-system` opts out.
|
||||
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
|
||||
const shared = flags["no-system"] === true || process.platform !== "linux";
|
||||
if (!shared) {
|
||||
if (linux.serverConf()) {
|
||||
console.log(c.dim(" one Linux user per account is already installed"));
|
||||
// The system unit serves; it reads the mode only when it starts.
|
||||
if (!wasServer) {
|
||||
const r = spawnSync("sudo", [
|
||||
"-n",
|
||||
"systemctl",
|
||||
"restart",
|
||||
"--no-block",
|
||||
"agenthub.service",
|
||||
]);
|
||||
console.log(
|
||||
r.status === 0
|
||||
? `${c.green("✔")} agenthub.service restarted`
|
||||
: c.yellow("! restart it: sudo systemctl restart agenthub")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(" Installing one Linux user per account (asks for your sudo password)…");
|
||||
try {
|
||||
await installSystem({}, settingsEnv);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`${e.message}\n Server mode is on, but every pane still runs as this account.` +
|
||||
"\n Fix it and run `agenthub server install-system`, or keep one shared" +
|
||||
" account with `agenthub server setup --no-system`."
|
||||
);
|
||||
}
|
||||
}
|
||||
// The admin made above (and anyone made before) gets their Linux account.
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await cmdUsers("provision", [], { all: true });
|
||||
}
|
||||
console.log(" Next:");
|
||||
console.log(
|
||||
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||
shared
|
||||
? " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||
: " 1. Log out and back in once, so your shell joins the agenthub groups"
|
||||
);
|
||||
console.log(
|
||||
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
|
||||
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
|
||||
);
|
||||
console.log(
|
||||
c.dim(" Members see only their own workspaces, but every pane still runs as this")
|
||||
);
|
||||
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
|
||||
if (shared) {
|
||||
console.log(
|
||||
c.yellow(
|
||||
process.platform === "linux"
|
||||
? "! --no-system: every pane runs as this one Linux account"
|
||||
: "! one Linux user per account needs Linux: every pane runs as this one account"
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
c.dim(" Members see only their own workspaces, but an agent in a pane can read")
|
||||
);
|
||||
console.log(
|
||||
c.dim(" every user's files — use it with people you trust (docs/SERVER-MODE.md).")
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
|
||||
);
|
||||
@@ -3987,7 +4041,7 @@ async function cmdServer(sub, flags) {
|
||||
}
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
@@ -4232,7 +4286,7 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
}
|
||||
pool.resetQuota();
|
||||
table(
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4241,6 +4295,8 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
a.livePanes,
|
||||
a.spend5h.toFixed(2),
|
||||
a.spend7d.toFixed(2),
|
||||
a.tokens5h,
|
||||
a.tokens7d,
|
||||
a.quota
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
@@ -4281,7 +4337,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||
table(
|
||||
["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"],
|
||||
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4289,6 +4345,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
a.livePanes,
|
||||
a.spend5h.toFixed(2),
|
||||
a.spend7d.toFixed(2),
|
||||
a.tokens5h,
|
||||
a.tokens7d,
|
||||
pct(a.quota?.fiveHour),
|
||||
pct(a.quota?.sevenDay),
|
||||
a.expired
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
|
||||
+5
-1
@@ -2,7 +2,8 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount, and
|
||||
src/lib/fontSize.ts). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
@@ -17,6 +18,9 @@
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
// Text size (src/lib/fontSize.ts, UI_FONT_SIZES): the root font size.
|
||||
var f = Number(localStorage.getItem("agenthub.ui.fontSize"));
|
||||
if (f === 14 || f === 17 || f === 18) r.style.fontSize = f + "px";
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
Generated
-42
@@ -1204,9 +1204,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1362,9 +1359,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1379,9 +1373,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1396,9 +1387,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1413,9 +1401,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1430,9 +1415,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1447,9 +1429,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1464,9 +1443,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1481,9 +1457,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1498,9 +1471,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1515,9 +1485,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1532,9 +1499,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1549,9 +1513,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1566,9 +1527,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
+33
-3
@@ -64,6 +64,7 @@ import { GlobalTooltip } from "./components/Tooltip";
|
||||
import { Toaster } from "./components/Toaster";
|
||||
import { ConfirmHost } from "./components/ConfirmHost";
|
||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||
import { usePermissions } from "./components/AuthGate";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
@@ -87,7 +88,11 @@ const SessionDetail = lazy(() =>
|
||||
const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||
const Administration = lazy(() =>
|
||||
import("./pages/Administration").then((m) => ({ default: m.Administration }))
|
||||
);
|
||||
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +112,11 @@ function Page({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A page only a shared server needs (the File Explorer): local mode goes home. */
|
||||
function ServerOnly({ children }: { children: ReactNode }) {
|
||||
return usePermissions().local ? <Navigate to="/" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
/** Keyed by the id so moving to another session starts from fresh state. */
|
||||
function SessionDetailRoute() {
|
||||
const { id } = useParams();
|
||||
@@ -211,13 +221,33 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
path="artifacts"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
<Artifacts />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
<ServerOnly>
|
||||
<Page>
|
||||
<FilesPage />
|
||||
</Page>
|
||||
</ServerOnly>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<Page>
|
||||
<Administration />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
{/* The Users page became Administration's first tab. */}
|
||||
<Route path="users" element={<Navigate to="/admin" replace />} />
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
|
||||
* previous user's data. `useAuth()` gives the sidebar the user, `logout`, and
|
||||
* `updateUser` (Settings → Account's saved display name shows at once);
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
@@ -16,6 +17,7 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -30,9 +32,17 @@ import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||
type AuthValue = {
|
||||
state: AuthState;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (patch: Partial<AuthUser>) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
|
||||
const AuthContext = createContext<AuthValue>({
|
||||
state: { mode: "local" },
|
||||
logout: async () => {},
|
||||
updateUser: () => {},
|
||||
});
|
||||
|
||||
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -53,6 +63,10 @@ export function usePermissions() {
|
||||
const isAdmin = local || user?.role === "admin";
|
||||
return {
|
||||
isAdmin,
|
||||
// Local mode: one person on their own machine — what only serves a
|
||||
// shared server (File Explorer, uploads, AgentHub's own git logins) is
|
||||
// hidden; their desktop and their own git config already do it.
|
||||
local,
|
||||
seesAll: local || (!!user && user.role !== "developer"),
|
||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
@@ -134,6 +148,15 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (account) void hydrate();
|
||||
}, [account]);
|
||||
|
||||
// Stable, so a page may list it as an effect dependency.
|
||||
const updateUser = useCallback(
|
||||
(patch: Partial<AuthUser>) =>
|
||||
setState((s) =>
|
||||
s && s.mode === "server" && s.user ? { ...s, user: { ...s.user, ...patch } } : s
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
@@ -142,7 +165,9 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
* ## Public surface
|
||||
@@ -81,6 +82,7 @@ import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
@@ -173,6 +175,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
<OnboardingTour />
|
||||
|
||||
@@ -6,36 +6,29 @@
|
||||
* user chose. A browser's picker cannot give a page that path, so the dialog
|
||||
* opens on the server's desktop — which is only this user's screen when the
|
||||
* browser runs on the same machine. Anywhere else (a LAN browser, a server
|
||||
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
|
||||
* the path.
|
||||
* with no desktop or no zenity/kdialog) it falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure) — for a file field
|
||||
* too, which then lists files and picks one on click.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type Ref } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { toast } from "../lib/toast";
|
||||
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
|
||||
|
||||
/** Server answers that mean "no native picker for this browser" — not a fault. */
|
||||
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
|
||||
|
||||
export function PathPickButton({
|
||||
kind = "dir",
|
||||
start,
|
||||
onPick,
|
||||
showLabel = false,
|
||||
buttonRef,
|
||||
}: {
|
||||
kind?: "dir" | "file";
|
||||
/** The field's current value — the picker opens there. */
|
||||
start?: string;
|
||||
onPick: (path: string) => void;
|
||||
showLabel?: boolean;
|
||||
/** Lets a field beside it open the same picker (a click on an empty path). */
|
||||
buttonRef?: Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -47,15 +40,10 @@ export function PathPickButton({
|
||||
try {
|
||||
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
|
||||
if (r.path) onPick(r.path);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
// A folder always has the in-app browser to fall back on, whatever went
|
||||
// wrong with the native one (a crashed dialog, one already open).
|
||||
if (kind === "dir") setFallback(true);
|
||||
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
|
||||
else {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} catch {
|
||||
// The in-app browser is the fallback whatever went wrong with the
|
||||
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
|
||||
setFallback(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -64,7 +52,6 @@ export function PathPickButton({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={busy}
|
||||
@@ -80,6 +67,7 @@ export function PathPickButton({
|
||||
<div className="contents" onClick={(e) => e.stopPropagation()}>
|
||||
<FolderBrowseModal
|
||||
open={fallback}
|
||||
kind={kind}
|
||||
initialPath={start?.trim() || undefined}
|
||||
onSelect={onPick}
|
||||
onClose={() => setFallback(false)}
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window. A question its Ask box cannot answer
|
||||
* locally opens here instead of leaving the page: the first message starts a
|
||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
||||
* forgets it (the session stays in the workspace), **Open in workspace**
|
||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||
* is gone, the next message starts a new one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { TerminalView } from "../run/TerminalView";
|
||||
import { PaneMenuCard } from "../run/PaneChat";
|
||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||
|
||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
const CHAT_KEY = "agenthub.pet.chat";
|
||||
const POLL_MS = 1500;
|
||||
const MENU_POLL_MS = 700;
|
||||
// A follow-up waits this long for the off-screen pane to attach.
|
||||
const SEND_TRIES = 20;
|
||||
const SEND_RETRY_MS = 250;
|
||||
|
||||
/** The session the chat is talking to. */
|
||||
export interface PetChatRef {
|
||||
laneId: number;
|
||||
runId: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export function readPetChat(): PetChatRef | null {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||
if (
|
||||
v &&
|
||||
typeof v.laneId === "number" &&
|
||||
typeof v.runId === "string" &&
|
||||
typeof v.tool === "string"
|
||||
)
|
||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||
} catch {
|
||||
/* unreadable: no chat */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePetChat(ref: PetChatRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||
else localStorage.removeItem(CHAT_KEY);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||
export function petChatTool(): string | null {
|
||||
const tool = readDefaultTool() ?? "claude";
|
||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||
}
|
||||
|
||||
type Line =
|
||||
| { key: string; role: "user" | "assistant"; text: string }
|
||||
| { key: string; role: "tools"; names: string[] };
|
||||
|
||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||
* `<system-reminder>`…) are not something the user said. */
|
||||
const humanText = (m: TranscriptMessage) =>
|
||||
m.content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!.trim())
|
||||
.filter((t) => t && !t.startsWith("<"))
|
||||
.join("\n\n");
|
||||
|
||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||
* each run of tool calls between them folded into one chip. */
|
||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||
const out: Line[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
const sender = m.sender ?? m.type;
|
||||
if (sender === "user") {
|
||||
const text = humanText(m);
|
||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||
return;
|
||||
}
|
||||
if (m.type !== "assistant") return;
|
||||
m.content.forEach((c, j) => {
|
||||
if (c.type === "text" && c.text?.trim()) {
|
||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||
} else if (c.type === "tool_use" && c.name) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "tools") last.names.push(c.name);
|
||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
const sender = m.sender ?? m.type;
|
||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||
if (sender === "user" && humanText(m)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** "Read ×2 · Bash" */
|
||||
function toolSummary(names: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||
}
|
||||
|
||||
interface PetChatProps {
|
||||
/** The pet's shown name, for the header and greeting. */
|
||||
name: string;
|
||||
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
||||
initialPrompt?: string | null;
|
||||
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
||||
onPromptTaken?: () => void;
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
/** Opens the chat's pane in its workspace. */
|
||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||
/** The thinking mood while a reply is being written. */
|
||||
onBusy?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
initialPrompt,
|
||||
onPromptTaken,
|
||||
onBack,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
}: PetChatProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// Sent but not in the transcript yet.
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remember = (ref: PetChatRef | null) => {
|
||||
writePetChat(ref);
|
||||
setChat(ref);
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
let live = true;
|
||||
const check = () =>
|
||||
api.run
|
||||
.get(chat.runId)
|
||||
.then((h) => {
|
||||
if (!live) return;
|
||||
if (h.status !== "running") remember(null);
|
||||
else if (h.sessionId) setSessionId(h.sessionId);
|
||||
})
|
||||
.catch(() => live && remember(null));
|
||||
void check();
|
||||
// Codex learns its session only after a while; Claude's is known at start.
|
||||
const timer = window.setInterval(() => {
|
||||
if (!sessionId) void check();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat?.runId, sessionId]);
|
||||
|
||||
// The conversation, polled while the window is open.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.transcript(sessionId, { limit: 60 })
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages((prev) =>
|
||||
prev.length === r.messages.length &&
|
||||
JSON.stringify(prev[prev.length - 1]) ===
|
||||
JSON.stringify(r.messages[r.messages.length - 1])
|
||||
? prev
|
||||
: r.messages
|
||||
);
|
||||
})
|
||||
.catch(() => undefined); // not started yet: the next poll asks again
|
||||
void load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
const read = () =>
|
||||
setMenu((prev) => {
|
||||
const next = readRunMenu(chat.runId);
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = chatLines(messages);
|
||||
// The pending line shows until the transcript has it.
|
||||
useEffect(() => {
|
||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||
setPending(null);
|
||||
}, [lines, pending]);
|
||||
const busy = starting || !!pending || isBusy(messages);
|
||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||
|
||||
// Keep the newest in sight.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length, pending, busy, menu]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const prompt = text.trim();
|
||||
if (!prompt || starting) return;
|
||||
setError(null);
|
||||
setPending(prompt);
|
||||
if (chat) {
|
||||
// Typed into the attached pane; it may still be attaching.
|
||||
for (let i = 0; i < SEND_TRIES; i++) {
|
||||
if (submitToRun(chat.runId, prompt)) return;
|
||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||
}
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(t("sagi.chat.notSent"));
|
||||
return;
|
||||
}
|
||||
const tool = petChatTool() ?? "claude";
|
||||
setStarting(true);
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(
|
||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t]
|
||||
);
|
||||
|
||||
// The Ask box's question, sent once.
|
||||
const taken = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialPrompt || taken.current) return;
|
||||
taken.current = true;
|
||||
onPromptTaken?.();
|
||||
void send(initialPrompt);
|
||||
}, [initialPrompt, onPromptTaken, send]);
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
const text = draft;
|
||||
setDraft("");
|
||||
void send(text);
|
||||
};
|
||||
|
||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||
const empty = lines.length === 0 && !pending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.chat.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
||||
<ArrowLeft size={16} />
|
||||
</IconButton>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
busy
|
||||
? "animate-pulse bg-status-warning"
|
||||
: chat
|
||||
? "bg-status-success"
|
||||
: "bg-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => remember(null)}
|
||||
disabled={!chat || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="text-3xl" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{t("sagi.chat.emptyTitle", { name })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((l) =>
|
||||
l.role === "tools" ? (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||
<span className="truncate">{toolSummary(l.names)}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : l.role === "user" ? (
|
||||
<div key={l.key} className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||
{l.text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||
<MarkdownContent text={l.text} dense prose />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{pending && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||
{pending}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{busy && !menu && (
|
||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{starting && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||
</p>
|
||||
)}
|
||||
{chat && !sessionId && !starting && (
|
||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* prompt, or the question the CLI asks */}
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{menu && chat ? (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
<PaneMenuCard
|
||||
menu={menu}
|
||||
onChoose={(i) => {
|
||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onAnswer={(text) => {
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.chat.placeholder", { name })}
|
||||
aria-label={t("sagi.chat.placeholder", { name })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc leaves the chat for the panel, not the whole mascot.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onBack();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || starting}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||
{chat && (
|
||||
<div className="pet-chat-term" aria-hidden>
|
||||
<TerminalView
|
||||
runId={chat.runId}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={false}
|
||||
// The pane ended: the conversation stays on screen, the next
|
||||
// message starts a new session.
|
||||
onExit={() => {
|
||||
writePetChat(null);
|
||||
setChat(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||
* Codex's own pet; a `moment` (a new session waves, a finish jumps, an error
|
||||
* fails, a wait waits, a new subagent reviews — `useSagiBrain`) plays its row
|
||||
* over the mood's, under the action; a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
@@ -18,7 +20,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const COLS = 8;
|
||||
@@ -58,12 +60,18 @@ const ROW: Record<Mood, number> = {
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** A moment's row: wave, jump, failed, waiting, review. It outranks the mood,
|
||||
* and the user's own action outranks it. */
|
||||
const MOMENT_ROW: Record<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 };
|
||||
/** Loops of a row before the sprite rests on its first frame. */
|
||||
const SPRITE_LOOPS = 4;
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
moment,
|
||||
gaze,
|
||||
size,
|
||||
reducedMotion,
|
||||
@@ -73,6 +81,7 @@ export function PetSprite({
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
moment?: PetMoment | null;
|
||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||
gaze?: number | null;
|
||||
size: number;
|
||||
@@ -80,8 +89,8 @@ export function PetSprite({
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && mood === "sleeping";
|
||||
const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && !moment && mood === "sleeping";
|
||||
// The cell on show, tagged with the row it was played for: a cell left over
|
||||
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||
@@ -95,13 +104,17 @@ export function PetSprite({
|
||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||
let i = 0;
|
||||
let loops = 0;
|
||||
let timer = 0;
|
||||
// `i` always indexes `list`: it wraps at its end.
|
||||
// `i` always indexes `list`: it wraps at its end. A few loops, then the
|
||||
// first frame rests — a sprite that never stops re-paints the page several
|
||||
// times a second for as long as it is open; a new mood or action plays again.
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(() => {
|
||||
if (++i === list.length) {
|
||||
i = 0;
|
||||
if (MOMENTS.has(row)) list = play(0);
|
||||
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
|
||||
}
|
||||
setCell(list[i]!);
|
||||
tick();
|
||||
@@ -132,6 +145,7 @@ export function PetSprite({
|
||||
src={url}
|
||||
alt=""
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
className="sagi-pet-image"
|
||||
width={size}
|
||||
height={size}
|
||||
@@ -141,7 +155,8 @@ export function PetSprite({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
|
||||
const pose =
|
||||
gaze != null && rows > GAZE_ROW && !action && !moment && mood === "idle" ? gaze : null;
|
||||
const shown =
|
||||
pose != null
|
||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { PetSprite } from "./PetSprite";
|
||||
@@ -87,8 +88,19 @@ import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { petName, sagiPrefs } from "./prefs";
|
||||
import type { SagiSubject } from "./brain";
|
||||
import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { PetChat, petChatTool } from "./PetChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
const STROKE_TURNS = 4;
|
||||
const STROKE_MS = 1200;
|
||||
const STROKE_COOLDOWN_MS = 1500;
|
||||
import "./sagi.css";
|
||||
|
||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||
@@ -137,15 +149,21 @@ export function usePrefersReducedMotion(): boolean {
|
||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||
* into view instead of being cropped.
|
||||
*/
|
||||
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
|
||||
/**
|
||||
* `front`: above the mascot itself — the pet menu, which may overlap it.
|
||||
* `beside`: at the mascot's side instead of above it — the chat window, on the
|
||||
* screen side away from the edge it docks to, its bottom level with the pet's.
|
||||
*/
|
||||
function SagiFlyout({
|
||||
anchor,
|
||||
children,
|
||||
front = false,
|
||||
beside = false,
|
||||
}: {
|
||||
anchor: Anchor;
|
||||
children: ReactNode;
|
||||
front?: boolean;
|
||||
beside?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
@@ -158,6 +176,18 @@ function SagiFlyout({
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
if (beside) {
|
||||
let left =
|
||||
anchor.side === "left"
|
||||
? anchor.left + anchor.size + FLYOUT_GAP
|
||||
: anchor.left - w - FLYOUT_GAP;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
let top = anchor.top + anchor.size - h;
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
||||
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
@@ -170,7 +200,7 @@ function SagiFlyout({
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
place();
|
||||
@@ -208,13 +238,19 @@ export function Sagi() {
|
||||
function SagiBody() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
const [open, setOpen] = useState(false);
|
||||
// The chat window (PetChat.tsx), and the Ask question it should send.
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const href = useSettingsHref();
|
||||
const brain = useSagiBrain();
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
@@ -224,11 +260,32 @@ function SagiBody() {
|
||||
() =>
|
||||
sagiPrefs.subscribe(() => {
|
||||
setPet(sagiPrefs.getPet());
|
||||
setName(petName(sagiPrefs.getPet()));
|
||||
setSize(sagiPrefs.getSize());
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// A pet worn before names rode along with the choice: look its name up once.
|
||||
const petId = pet.id;
|
||||
const unnamed = "kind" in pet && !pet.name;
|
||||
useEffect(() => {
|
||||
if (!unnamed) return;
|
||||
let live = true;
|
||||
api.pets
|
||||
.list()
|
||||
.then((list) => {
|
||||
const found = list.pets.find((p) => p.id === petId);
|
||||
const cur = sagiPrefs.getPet();
|
||||
if (live && found && cur.id === petId && "kind" in cur)
|
||||
sagiPrefs.setPet({ ...cur, name: found.displayName });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [petId, unnamed]);
|
||||
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||
useEffect(() => {
|
||||
@@ -239,6 +296,7 @@ function SagiBody() {
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -279,27 +337,53 @@ function SagiBody() {
|
||||
[navigate]
|
||||
);
|
||||
|
||||
// A session a bubble or the panel names: its live pane when a dashboard run
|
||||
// holds it, else its workspace's replay, else its analysis page. Opening
|
||||
// never resumes anything.
|
||||
const openSession = useCallback(
|
||||
(s: SagiSubject) => {
|
||||
const go = (runs?: Parameters<typeof workspaceHref>[1]) => {
|
||||
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||
brain.dismissBubble();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
};
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => go(r.items))
|
||||
.catch(() => go());
|
||||
},
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
const onAsk = useCallback(
|
||||
(query: string): string | null => {
|
||||
const result = matchIntent(query, brain.status);
|
||||
if (result.kind === "answer") return result.text;
|
||||
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
||||
// fresh claude session in it carrying the user's prompt as its first
|
||||
// message, then open that workspace's session view on the new pane.
|
||||
// Fire-and-forget — the panel already clears its composer on null and
|
||||
// closes; errors surface as an answer instead of silently swallowing.
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return result.text;
|
||||
}
|
||||
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||
// which starts a session of the default AI tool and shows it as chat.
|
||||
if (petChatTool()) {
|
||||
setChatPrompt(result.prompt);
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
return null;
|
||||
}
|
||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
brain.setThinking(true);
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const run = await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: prompt,
|
||||
permissionMode: "acceptEdits",
|
||||
const tool = readDefaultTool();
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||
});
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -310,9 +394,32 @@ function SagiBody() {
|
||||
})();
|
||||
return null;
|
||||
},
|
||||
[brain, navigate, t]
|
||||
[brain, navigate, t, name]
|
||||
);
|
||||
|
||||
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||
const onStroke = (e: React.PointerEvent) => {
|
||||
if (e.buttons !== 0 || e.pointerType === "touch") return;
|
||||
const s = stroke.current;
|
||||
const now = performance.now();
|
||||
const dx = e.clientX - s.x;
|
||||
s.x = e.clientX;
|
||||
if (Math.abs(dx) < 2 || now < s.until) return;
|
||||
const dir = Math.sign(dx);
|
||||
if (s.dir && dir !== s.dir) {
|
||||
s.turns = [...s.turns.filter((t0) => now - t0 < STROKE_MS), now];
|
||||
if (s.turns.length >= STROKE_TURNS) {
|
||||
s.turns = [];
|
||||
s.until = now + STROKE_COOLDOWN_MS;
|
||||
brain.pat();
|
||||
}
|
||||
}
|
||||
s.dir = dir;
|
||||
};
|
||||
|
||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
@@ -329,6 +436,13 @@ function SagiBody() {
|
||||
{!place.dragging && open && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SagiPanel
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
onPat={brain.pat}
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
@@ -340,6 +454,29 @@ function SagiBody() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1), and kept mounted: unmounting would detach its terminal and
|
||||
lose a half-typed message. */}
|
||||
{chatOpen && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
name={name}
|
||||
initialPrompt={chatPrompt}
|
||||
onPromptTaken={() => setChatPrompt(null)}
|
||||
onBack={() => {
|
||||
setChatOpen(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClose={() => setChatOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setChatOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!place.dragging && menu && (
|
||||
<SagiFlyout anchor={anchor} front>
|
||||
<PetMenu
|
||||
@@ -348,15 +485,19 @@ function SagiBody() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/appearance#pet");
|
||||
navigate(href("appearance#pet"));
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && brain.bubble && (
|
||||
{!open && !menu && !chatOpen && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
onDismiss={brain.dismissBubble}
|
||||
onOpen={openSession}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
@@ -368,34 +509,47 @@ function SagiBody() {
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerMove={(e) => {
|
||||
place.onPointerMove(e);
|
||||
onStroke(e);
|
||||
}}
|
||||
onPointerUp={place.onPointerUp}
|
||||
onKeyDown={place.onKeyDown}
|
||||
onClick={() => {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
setOpen((v) => !v);
|
||||
if (chatOpen) setChatOpen(false);
|
||||
else setOpen((v) => !v);
|
||||
}}
|
||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
|
||||
aria-expanded={open}
|
||||
title={t("sagi.avatarTitle")}
|
||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open || chatOpen}
|
||||
title={t("sagi.avatarTitle", { name })}
|
||||
>
|
||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
|
||||
<SagiAvatar
|
||||
mood={brain.mood}
|
||||
moment={reducedMotion ? null : brain.moment?.kind}
|
||||
reducedMotion={reducedMotion}
|
||||
size={size}
|
||||
/>
|
||||
) : (
|
||||
<PetSprite
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||
// A moment (a pat's hop) outranks the hover wave, or stroking the
|
||||
// pet would only ever show it waving.
|
||||
action={place.moving ? `run-${place.moving}` : hover && !brain.moment ? "wave" : null}
|
||||
moment={brain.moment?.kind}
|
||||
gaze={gaze}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
@@ -403,10 +557,28 @@ function SagiBody() {
|
||||
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
/>
|
||||
)}
|
||||
{brain.status.errorCount > 0 && (
|
||||
{brain.reaction && (
|
||||
<span key={brain.reaction.key} className="sagi-reaction" aria-live="polite">
|
||||
<span className="sagi-hearts" aria-hidden>
|
||||
<span>💜</span>
|
||||
<span>💗</span>
|
||||
<span>💜</span>
|
||||
</span>
|
||||
<span className="sagi-reaction-text" data-side={place.side}>
|
||||
{brain.reaction.text}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{brain.status.errorCount > 0 ? (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
</span>
|
||||
) : (
|
||||
brain.status.waitingCount > 0 && (
|
||||
<span className="sagi-error-dot" data-kind="waiting" aria-hidden>
|
||||
{brain.status.waitingCount > 9 ? "9+" : brain.status.waitingCount}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -37,12 +37,14 @@
|
||||
* ============================================================================= */
|
||||
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
interface SagiAvatarProps {
|
||||
mood: Mood;
|
||||
reducedMotion: boolean;
|
||||
size?: number;
|
||||
/** A one-shot reaction (hop, wiggle, flinch…) played over the mood. */
|
||||
moment?: PetMoment | null;
|
||||
}
|
||||
|
||||
const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space
|
||||
@@ -88,6 +90,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
mood,
|
||||
reducedMotion,
|
||||
size = 68,
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
@@ -148,6 +151,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
ref={rootRef}
|
||||
className="sagi-avatar"
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
data-reduced={reducedMotion ? "1" : "0"}
|
||||
width={size}
|
||||
height={size}
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Play,
|
||||
Activity,
|
||||
LayoutList,
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
@@ -61,12 +58,29 @@ import {
|
||||
AlertTriangle,
|
||||
Hourglass,
|
||||
Radio,
|
||||
Heart,
|
||||
ChevronRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiStatus } from "./brain";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
|
||||
interface SagiPanelProps {
|
||||
/** The name the pet goes by now. */
|
||||
name: string;
|
||||
/** The pet's own name, which an empty nickname falls back to. */
|
||||
ownName: string;
|
||||
/** The user's nickname for the pet, "" when none. */
|
||||
nickname: string;
|
||||
onRename: (name: string) => void;
|
||||
onPat: () => void;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: ReadonlyArray<AttentionItem>;
|
||||
onOpenSession: (s: SagiSubject) => void;
|
||||
status: SagiStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
@@ -78,6 +92,13 @@ interface SagiPanelProps {
|
||||
}
|
||||
|
||||
export function SagiPanel({
|
||||
name,
|
||||
ownName,
|
||||
nickname,
|
||||
onRename,
|
||||
onPat,
|
||||
attention,
|
||||
onOpenSession,
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
@@ -89,6 +110,13 @@ export function SagiPanel({
|
||||
const { t } = useTranslation("settings");
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
// The name being typed, or null while not renaming.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const saveName = () => {
|
||||
if (renaming === null) return;
|
||||
onRename(renaming);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -101,7 +129,7 @@ export function SagiPanel({
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label")}
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
@@ -109,7 +137,40 @@ export function SagiPanel({
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
|
||||
{renaming === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||
onClick={() => setRenaming(nickname)}
|
||||
aria-label={t("sagi.panel.rename", { name })}
|
||||
title={t("sagi.panel.rename", { name })}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||
value={renaming}
|
||||
maxLength={PET_NAME_MAX}
|
||||
placeholder={ownName}
|
||||
aria-label={t("sagi.panel.renameLabel")}
|
||||
title={t("sagi.panel.renameHint")}
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={saveName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveName();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc cancels the rename only, not the whole panel.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setRenaming(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected
|
||||
@@ -126,13 +187,23 @@ export function SagiPanel({
|
||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close")}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={onPat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
@@ -158,29 +229,55 @@ export function SagiPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* the sessions that need the user, by name */}
|
||||
{attention.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.panel.attention")}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
||||
onClick={() => onOpenSession(item)}
|
||||
title={item.cwd ?? item.id}
|
||||
>
|
||||
{item.kind === "error" ? (
|
||||
<AlertTriangle
|
||||
size={13}
|
||||
className="shrink-0 text-status-danger"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
||||
{subjectLabel(item)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">
|
||||
{item.kind === "error"
|
||||
? t("sagi.panel.attentionError")
|
||||
: t("sagi.panel.attentionWaiting")}
|
||||
</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{attention.length > ATTENTION_SHOWN && (
|
||||
<button
|
||||
className="mt-1 text-[11px] text-accent hover:underline"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
>
|
||||
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton
|
||||
icon={Play}
|
||||
label={t("sagi.panel.runClaude")}
|
||||
onClick={() => onNavigate("/workspace")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Activity}
|
||||
label={t("sagi.panel.activity")}
|
||||
onClick={() => onNavigate("/activity")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={LayoutList}
|
||||
label={t("sagi.panel.sessions")}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label={t("sagi.panel.errored")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||
@@ -198,10 +295,10 @@ export function SagiPanel({
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.panel.askPlaceholder")}
|
||||
placeholder={t("sagi.panel.askPlaceholder", { name })}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label={t("sagi.panel.ask")}
|
||||
aria-label={t("sagi.panel.ask", { name })}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -284,7 +381,7 @@ function ActionButton({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file SpeechBubble.tsx
|
||||
* @description Transient speech bubble rendered above the Sagi mascot.
|
||||
* Pure presentation — visibility timing and quip selection live in
|
||||
* {@link useSagiBrain}; this component only paints the bubble and handles
|
||||
* user dismissal.
|
||||
* Pure presentation — visibility timing, coalescing and quip selection live in
|
||||
* {@link useSagiBrain}; this component paints the line, the session(s) it is
|
||||
* about, an Open button when it is about exactly one, and handles dismissal.
|
||||
*
|
||||
* ## Accessibility
|
||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
||||
* to dismiss early.
|
||||
* new quips without interrupting current speech. Click the bubble to dismiss
|
||||
* it early; its Open button opens the session instead.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -52,30 +52,52 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiBubble } from "./useSagiBrain";
|
||||
import type { SagiSubject } from "./brain";
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
text: string;
|
||||
bubble: SagiBubble;
|
||||
/** Called when the user clicks to dismiss. */
|
||||
onDismiss: () => void;
|
||||
/** Opens the session the bubble is about. */
|
||||
onOpen?: (subject: SagiSubject) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated speech bubble above Sagi.
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
export function SpeechBubble({ bubble, onDismiss, onOpen }: SpeechBubbleProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { text, detail, subject, tone } = bubble;
|
||||
return (
|
||||
<div
|
||||
className="sagi-bubble sagi-bubble-enter"
|
||||
data-tone={tone}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
title={t("sagi.bubbleDismiss")}
|
||||
>
|
||||
{text}
|
||||
<div>{text}</div>
|
||||
{(detail || (subject && onOpen)) && (
|
||||
<div className="sagi-bubble-detail">
|
||||
{detail && <span className="sagi-bubble-subject">{detail}</span>}
|
||||
{subject && onOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="sagi-bubble-open"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen(subject);
|
||||
}}
|
||||
>
|
||||
{t("sagi.bubbleOpen")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file PetChat.test.ts
|
||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chatLines } from "../PetChat";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
||||
|
||||
describe("chatLines", () => {
|
||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
||||
const lines = chatLines([
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", text: "hmm" },
|
||||
{ type: "tool_use", name: "Read" },
|
||||
],
|
||||
}),
|
||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello!" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
||||
"hi",
|
||||
["Read", "Read"],
|
||||
"hello!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops Claude Code's own injected lines", () => {
|
||||
const lines = chatLines([
|
||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -261,7 +261,7 @@ describe("PetMenu", () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
});
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("PetMenu", () => {
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
|
||||
);
|
||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
@@ -313,11 +313,11 @@ describe("PetSettings", () => {
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image", name: "My cat" });
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
@@ -329,7 +329,7 @@ describe("PetSettings", () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -88,6 +88,93 @@ describe("Sagi widget", () => {
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the errored session in its bubble and in the panel", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
type: "session_updated",
|
||||
data: { id: "a", status: "error", name: "Fix login", cwd: "/w/app", lane_id: 3 } as Session,
|
||||
timestamp: "t",
|
||||
});
|
||||
});
|
||||
const bubble = screen.getByRole("status");
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet about a cleared error when that session updates again", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear alerts" }));
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||
expect(within(btn).queryByText("1")).not.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("renames the pet from the panel header", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands a question it cannot answer to the chat window", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the panel is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
const pet = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||
expect(pet.querySelector(".sagi-reaction-text")?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stroking the pet back and forth pats it", () => {
|
||||
renderSagi();
|
||||
const pet = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
const xs = [100, 130, 100, 130, 100, 130, 100];
|
||||
act(() => {
|
||||
for (const x of xs) fireEvent.pointerMove(pet, { clientX: x, clientY: 50, buttons: 0 });
|
||||
});
|
||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the live count in the panel status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
|
||||
@@ -225,6 +225,43 @@ describe("clearErrors", () => {
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
expect(s.worriedUntil).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a cleared session quiet while it stays errored", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
const again = reduceSagi(s, sessionMsg("b", "error", T0 + 1), T0 + 1);
|
||||
expect(again.pulse).toBeNull();
|
||||
expect(statusOf(again.state).errorCount).toBe(0);
|
||||
expect(again.state.worriedUntil).toBe(0);
|
||||
const ev = reduceSagi(
|
||||
again.state,
|
||||
{ type: "new_event", data: { session_id: "b", event_type: "toolError" }, timestamp: "" } as WSMessage,
|
||||
T0 + 2
|
||||
);
|
||||
expect(ev.pulse).toBeNull();
|
||||
expect(seedSessions(s, [{ id: "b", status: "error" }], T0).sessions.b).toBeUndefined();
|
||||
});
|
||||
|
||||
it("alerts again once a cleared session recovers and fails anew", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
|
||||
expect(s.dismissed).not.toHaveProperty("b");
|
||||
const r = reduceSagi(s, sessionMsg("b", "error"), T0);
|
||||
expect(r.pulse).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error alerts fire on the transition only", () => {
|
||||
it("does not re-alert on each update of a still-errored session", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "error"), T0));
|
||||
const r = reduceSagi(s, sessionMsg("a", "error"), T0 + 10_000);
|
||||
expect(r.pulse).toBeNull();
|
||||
expect(statusOf(r.state).errorCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Reference the imported constant so it is exercised and tsc-clean.
|
||||
|
||||
@@ -75,4 +75,27 @@ describe("matchIntent", () => {
|
||||
const r = matchIntent("Refactor my auth module", status());
|
||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
||||
});
|
||||
|
||||
it("understands Vietnamese keywords", () => {
|
||||
const r = matchIntent("có lỗi nào không?", status({ errorCount: 2 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("2 sessions errored");
|
||||
expect(matchIntent("đang chạy gì vậy", status({ liveCount: 1 })).kind).toBe("answer");
|
||||
expect(matchIntent("tình hình sao rồi", status()).kind).toBe("answer");
|
||||
});
|
||||
|
||||
it("names the sessions it reports when it knows them", () => {
|
||||
const r = matchIntent("any errors?", status({ errorCount: 1 }), {
|
||||
attention: [{ id: "a", kind: "error", name: "Fix login", cwd: null, lane_id: null }],
|
||||
});
|
||||
if (r.kind === "answer") expect(r.text).toContain("errored: Fix login");
|
||||
});
|
||||
|
||||
it("introduces itself by the pet's name and reacts to a pat", () => {
|
||||
const who = matchIntent("who are you", status(), { name: "Mochi" });
|
||||
expect(who).toMatchObject({ kind: "answer", moment: "wave" });
|
||||
if (who.kind === "answer") expect(who.text).toContain("Mochi");
|
||||
expect(matchIntent("pat", status())).toMatchObject({ kind: "answer", moment: "jump" });
|
||||
// "pat" inside another word is not a pat.
|
||||
expect(matchIntent("update the path", status()).kind).toBe("handoff");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,14 +137,7 @@ import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from
|
||||
|
||||
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||
@@ -152,13 +145,21 @@ export type Mood =
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type SagiPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
|
||||
|
||||
/** A short animation the pet plays on an event or a pat, over its mood. */
|
||||
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
|
||||
|
||||
/** Which moment each pulse plays: greet a new session, hop at a finish,
|
||||
* inspect a new subagent, tap a foot while you are awaited, flinch at an error. */
|
||||
export const MOMENT_OF_PULSE: Record<NonNullable<SagiPulse>, PetMoment> = {
|
||||
session_start: "wave",
|
||||
session_done: "jump",
|
||||
run_done: "jump",
|
||||
subagent_spawn: "review",
|
||||
waiting: "wait",
|
||||
error: "fail",
|
||||
};
|
||||
|
||||
export interface SagiStatus {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
@@ -182,6 +183,47 @@ export interface SagiState {
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
thinking: boolean;
|
||||
/** Errored sessions the user cleared ("clear alerts"). Their further
|
||||
* updates, agent errors and failure events stay silent until the session
|
||||
* goes active again or ends — otherwise every update of a still-errored
|
||||
* session re-raised the alert the user had just dismissed. */
|
||||
dismissed: Record<string, true>;
|
||||
/** Name and owning workspace of each tracked session, so a bubble or the
|
||||
* panel can say WHICH session it means and open it. */
|
||||
info: Record<string, SessionInfo>;
|
||||
}
|
||||
|
||||
/** What Sagi knows about a session beyond its status. */
|
||||
export interface SessionInfo {
|
||||
name: string | null;
|
||||
cwd: string | null;
|
||||
lane_id: number | null;
|
||||
}
|
||||
|
||||
/** The session a pulse is about, when it is about one. */
|
||||
export interface SagiSubject extends SessionInfo {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** A session that needs the user: errored, or waiting on their input. */
|
||||
export interface AttentionItem extends SagiSubject {
|
||||
kind: "error" | "waiting";
|
||||
}
|
||||
|
||||
const infoOf = (s: {
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}): SessionInfo => ({ name: s.name ?? null, cwd: s.cwd ?? null, lane_id: s.lane_id ?? null });
|
||||
|
||||
/** A session's label for a bubble: its name, else its folder, else a short id. */
|
||||
export function subjectLabel(s: SagiSubject): string {
|
||||
if (s.name && s.name.trim()) return s.name.trim();
|
||||
const base = s.cwd
|
||||
?.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop();
|
||||
return base || s.id.slice(0, 8);
|
||||
}
|
||||
|
||||
// Tunable timing constants (ms).
|
||||
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
dismissed: {},
|
||||
info: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,16 +300,28 @@ export function reduceSagi(
|
||||
state: SagiState,
|
||||
msg: WSMessage,
|
||||
now: number
|
||||
): { state: SagiState; pulse: SagiPulse } {
|
||||
): { state: SagiState; pulse: SagiPulse; subject?: SagiSubject } {
|
||||
// The tracked session an agent/event message belongs to, when known.
|
||||
const known = (id: string | undefined): SagiSubject | undefined =>
|
||||
id ? { id, ...(state.info[id] ?? { name: null, cwd: null, lane_id: null }) } : undefined;
|
||||
switch (msg.type) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info, [s.id]: infoOf(s) };
|
||||
const subject: SagiSubject = { id: s.id, ...info[s.id]! };
|
||||
let dismissed = state.dismissed;
|
||||
let pulse: SagiPulse = null;
|
||||
let happyUntil = state.happyUntil;
|
||||
let worriedUntil = state.worriedUntil;
|
||||
// Anything but a (still) errored status ends a dismissal: a later error
|
||||
// of that session is a new one and must alert again.
|
||||
if (s.status !== "error" && s.id in dismissed) {
|
||||
dismissed = { ...dismissed };
|
||||
delete dismissed[s.id];
|
||||
}
|
||||
|
||||
if (s.status === "active") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
@@ -280,12 +336,20 @@ export function reduceSagi(
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} else if (s.status === "error") {
|
||||
if (s.id in dismissed) {
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
// Alert on the transition INTO error only; a session that stays
|
||||
// errored keeps getting updates and must not re-alert on each one.
|
||||
if (sessions[s.id] !== "error") {
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
}
|
||||
sessions[s.id] = "error";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
delete info[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
@@ -293,31 +357,46 @@ export function reduceSagi(
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
state: {
|
||||
...state,
|
||||
sessions,
|
||||
dismissed,
|
||||
info,
|
||||
happyUntil,
|
||||
worriedUntil,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
pulse,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
||||
return {
|
||||
state: { ...state, lastActivityAt: now },
|
||||
pulse,
|
||||
subject: pulse ? known(a.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_updated": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
@@ -325,7 +404,8 @@ export function reduceSagi(
|
||||
|
||||
case "new_event": {
|
||||
const e = msg.data as DashboardEvent;
|
||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
||||
const isFailure =
|
||||
!!e && FAILURE_EVENT_TYPES.has(e.event_type) && !(e.session_id in state.dismissed);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
@@ -333,6 +413,7 @@ export function reduceSagi(
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
subject: isFailure ? known(e.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,24 +442,60 @@ export function reduceSagi(
|
||||
*/
|
||||
export function seedSessions(
|
||||
state: SagiState,
|
||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||
rows: ReadonlyArray<{
|
||||
id: string;
|
||||
status: string;
|
||||
awaiting_input_since?: string | null;
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}>,
|
||||
now: number
|
||||
): SagiState {
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
if (r.status === "error") {
|
||||
if (r.id in state.dismissed) continue;
|
||||
sessions[r.id] = "error";
|
||||
} else if (r.status === "active") {
|
||||
sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
} else continue; // completed / abandoned: leave untracked.
|
||||
info[r.id] = infoOf(r);
|
||||
}
|
||||
return { ...state, sessions, lastActivityAt: now };
|
||||
return { ...state, sessions, info, lastActivityAt: now };
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||
/** Errored sessions first, then the ones waiting on the user — the panel's list. */
|
||||
export function attentionOf(state: SagiState): AttentionItem[] {
|
||||
const out: AttentionItem[] = [];
|
||||
for (const kind of ["error", "waiting"] as const) {
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== kind) continue;
|
||||
out.push({
|
||||
id,
|
||||
kind,
|
||||
...(state.info[id] ?? { name: null, cwd: null, lane_id: null }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts") and
|
||||
* remember them as dismissed, so their next update does not bring the alert
|
||||
* straight back. */
|
||||
export function clearErrors(state: SagiState): SagiState {
|
||||
const sessions: SagiState["sessions"] = {};
|
||||
const dismissed = { ...state.dismissed };
|
||||
const info = { ...state.info };
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== "error") sessions[id] = s;
|
||||
else {
|
||||
dismissed[id] = true;
|
||||
delete info[id];
|
||||
}
|
||||
}
|
||||
return { ...state, sessions, worriedUntil: 0 };
|
||||
return { ...state, sessions, dismissed, info, worriedUntil: 0 };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
||||
* small set of intents answerable from cached dashboard status. Anything it
|
||||
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
||||
* Pure function - no network, no DOM - so it's fully unit-testable.
|
||||
* Understands English and Vietnamese keywords, names the sessions it reports
|
||||
* when it knows them, and answers in the UI language (`settings:sagi.intent`).
|
||||
* No network, no DOM - so it's fully unit-testable.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -28,6 +30,8 @@
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AskContext` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `statusSummary` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -54,70 +58,157 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { SagiStatus } from "./brain";
|
||||
import i18n from "../../i18n";
|
||||
import { subjectLabel, type AttentionItem, type PetMoment, type SagiStatus } from "./brain";
|
||||
import { pickLine } from "./quips";
|
||||
|
||||
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
|
||||
export type AskResult =
|
||||
{ kind: "answer"; text: string; moment?: PetMoment } | { kind: "handoff"; prompt: string };
|
||||
|
||||
const plural = (n: number) => (n === 1 ? "" : "s");
|
||||
/** What the answer may mention beyond the counts. */
|
||||
export interface AskContext {
|
||||
/** The pet's shown name. */
|
||||
name?: string;
|
||||
/** Errored and waiting sessions, named (`attentionOf`). */
|
||||
attention?: ReadonlyArray<AttentionItem>;
|
||||
}
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus): AskResult {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||
};
|
||||
}
|
||||
const t = (key: string, vars?: Record<string, unknown>) =>
|
||||
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
|
||||
|
||||
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
|
||||
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
|
||||
const names = (items ?? []).filter((i) => i.kind === kind).map(subjectLabel);
|
||||
if (names.length === 0) return "";
|
||||
const shown = names.slice(0, 3).join(", ");
|
||||
return `: ${names.length > 3 ? `${shown}…` : shown}`;
|
||||
}
|
||||
|
||||
/** "2 sessions live, 1 waiting on you" — the one-line status a greeting carries. */
|
||||
export function statusSummary(status: SagiStatus): string {
|
||||
const parts = [i18n.t("settings:sagi.greet.summary", { count: status.liveCount })];
|
||||
if (status.waitingCount > 0)
|
||||
parts.push(i18n.t("settings:sagi.greet.summaryWaiting", { count: status.waitingCount }));
|
||||
if (status.errorCount > 0)
|
||||
parts.push(i18n.t("settings:sagi.greet.summaryErrors", { count: status.errorCount }));
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus, ctx: AskContext = {}): AskResult {
|
||||
const q = query.trim().toLowerCase().normalize("NFC");
|
||||
const name = ctx.name || "Sagi";
|
||||
if (!q) return { kind: "answer", text: t("empty") };
|
||||
|
||||
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
||||
// Whole-word match, for short words that hide inside longer ones ("hi" in "this").
|
||||
const word = (...words: string[]) =>
|
||||
words.some((w) => new RegExp(`(^|[^\\p{L}])${w}($|[^\\p{L}])`, "u").test(q));
|
||||
|
||||
if (has("help", "what can you", "what do you do")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
||||
};
|
||||
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
|
||||
return { kind: "answer", text: t("help", { name }) };
|
||||
}
|
||||
|
||||
if (has("who are you", "your name", "bạn là ai", "tên gì", "tên bạn", "là ai")) {
|
||||
return { kind: "answer", text: t("whoami", { name }), moment: "wave" };
|
||||
}
|
||||
|
||||
if (has("thank", "thx", "cảm ơn", "cám ơn") || word("ty", "tks")) {
|
||||
return { kind: "answer", text: pickLine("intent.thanks"), moment: "jump" };
|
||||
}
|
||||
|
||||
if (
|
||||
has("good boy", "good girl", "cute", "ngoan", "dễ thương", "xoa đầu", "vuốt ve", "giỏi quá") ||
|
||||
word("pat")
|
||||
) {
|
||||
return { kind: "answer", text: pickLine("pat"), moment: "jump" };
|
||||
}
|
||||
|
||||
// Errors first: "any failed runs" should report errors, not live count.
|
||||
if (has("error", "broke", "broken", "fail", "wrong", "crash")) {
|
||||
if (
|
||||
has("error", "broke", "broken", "fail", "wrong", "crash", "lỗi", "hỏng", "thất bại", "sự cố")
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.errorCount > 0
|
||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
||||
: "no errors - all clean 🐾",
|
||||
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
|
||||
: t("errorsNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) {
|
||||
if (
|
||||
has(
|
||||
"waiting",
|
||||
"stuck",
|
||||
"blocked",
|
||||
"input",
|
||||
"my turn",
|
||||
"paused",
|
||||
"chờ",
|
||||
"đợi",
|
||||
"kẹt",
|
||||
"tới lượt",
|
||||
"đến lượt"
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.waitingCount > 0
|
||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
||||
: "nothing's waiting on you right now 🐾",
|
||||
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
|
||||
: t("waitingNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
||||
if (
|
||||
has(
|
||||
"running",
|
||||
"active",
|
||||
"live",
|
||||
"going on",
|
||||
"happening",
|
||||
"in progress",
|
||||
"đang chạy",
|
||||
"đang làm",
|
||||
"hoạt động"
|
||||
)
|
||||
) {
|
||||
const tail = status.waitingCount > 0 ? t("liveTail", { count: status.waitingCount }) : "";
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.liveCount > 0
|
||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
||||
: "nothing's running right now - all quiet.",
|
||||
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) {
|
||||
if (
|
||||
has(
|
||||
"status",
|
||||
"summary",
|
||||
"overview",
|
||||
"how are things",
|
||||
"how's it",
|
||||
"how is it",
|
||||
"tình hình",
|
||||
"trạng thái",
|
||||
"tổng quan",
|
||||
"thế nào",
|
||||
"sao rồi"
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
||||
status.connected ? "connected" : "offline"
|
||||
}.`,
|
||||
text: t("status", {
|
||||
live: status.liveCount,
|
||||
waiting: status.waitingCount,
|
||||
errors: status.errorCount,
|
||||
conn: t(status.connected ? "connected" : "offline"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (word("hi", "hello", "hey", "yo", "chào", "xin chào", "alo")) {
|
||||
return { kind: "answer", text: t("hello", { summary: statusSummary(status) }), moment: "wave" };
|
||||
}
|
||||
|
||||
return { kind: "handoff", prompt: query.trim() };
|
||||
}
|
||||
|
||||
@@ -56,12 +56,19 @@ const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||
const PET_KEY = "agenthub.pet";
|
||||
const SIZE_KEY = "agenthub.pet.size";
|
||||
const DISMISSED_KEY = "agenthub.pet.dismissedErrors";
|
||||
/** Per-pet nicknames the user typed, `{ [petId]: name }`. */
|
||||
const NAMES_KEY = "agenthub.pet.names";
|
||||
export const PET_NAME_MAX = 40;
|
||||
/** Most recent dismissed error sessions kept across reloads. */
|
||||
const DISMISSED_MAX = 200;
|
||||
/** Sagi's own size, and the slider's range. */
|
||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||
|
||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||
* The kind rides along so the mascot renders without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
||||
* The kind (and its display name) ride along so the mascot renders, and
|
||||
* introduces itself, without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
|
||||
const SAGI: PetChoice = { id: "sagi" };
|
||||
|
||||
function readPet(): PetChoice {
|
||||
@@ -69,6 +76,7 @@ function readPet(): PetChoice {
|
||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}> | null;
|
||||
if (
|
||||
p &&
|
||||
@@ -76,13 +84,34 @@ function readPet(): PetChoice {
|
||||
p.id !== "sagi" &&
|
||||
(p.kind === "codex" || p.kind === "image")
|
||||
)
|
||||
return { id: p.id, kind: p.kind };
|
||||
return typeof p.name === "string" && p.name
|
||||
? { id: p.id, kind: p.kind, name: p.name.slice(0, PET_NAME_MAX) }
|
||||
: { id: p.id, kind: p.kind };
|
||||
} catch {
|
||||
/* unreadable: Sagi */
|
||||
}
|
||||
return SAGI;
|
||||
}
|
||||
|
||||
function readNames(): Record<string, string> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(NAMES_KEY) ?? "{}") as unknown;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, v] of Object.entries(raw)) if (typeof v === "string" && v) out[id] = v;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The name the pet goes by: the user's nickname, else the pet's own, else Sagi. */
|
||||
export function petName(pet: PetChoice): string {
|
||||
const nick = readNames()[pet.id];
|
||||
if (nick) return nick;
|
||||
return ("name" in pet && pet.name) || "Sagi";
|
||||
}
|
||||
|
||||
function readSize(): number {
|
||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||
return Number.isFinite(n) && n > 0
|
||||
@@ -181,6 +210,37 @@ export const sagiPrefs = {
|
||||
SIZE_KEY,
|
||||
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
||||
),
|
||||
/** Name a pet; an empty name gives it back its own. */
|
||||
setNickname: (id: string, name: string) => {
|
||||
const names = readNames();
|
||||
const clean = name.replace(/\s+/g, " ").trim().slice(0, PET_NAME_MAX);
|
||||
if (clean) names[id] = clean;
|
||||
else delete names[id];
|
||||
writeAndNotify(NAMES_KEY, JSON.stringify(names));
|
||||
},
|
||||
/** The user's nickname for a pet, "" when none. */
|
||||
getNickname: (id: string) => readNames()[id] ?? "",
|
||||
/** Errored session ids the user cleared, so a reload keeps them quiet. */
|
||||
getDismissed: (): Record<string, true> => {
|
||||
try {
|
||||
const ids = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]");
|
||||
const out: Record<string, true> = {};
|
||||
if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") out[id] = true;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
setDismissed: (dismissed: Record<string, true>) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DISMISSED_KEY,
|
||||
JSON.stringify(Object.keys(dismissed).slice(-DISMISSED_MAX))
|
||||
);
|
||||
} catch {
|
||||
// Best-effort, like every pref here; no prefs event — widget-local state.
|
||||
}
|
||||
},
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file quips.ts
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
|
||||
* plus a deterministic-by-injection picker. Pure data + a pure function so it
|
||||
* can be unit-tested without randomness leaking in.
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood
|
||||
* (and greetings, pat reactions), read from the `settings:sagi.*` i18n arrays
|
||||
* so they follow the UI language, plus a deterministic-by-injection picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -24,10 +24,13 @@
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
* - `../../i18n`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickLine` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `greetingKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -59,76 +62,60 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import type { Mood, SagiPulse } from "./brain";
|
||||
|
||||
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
||||
|
||||
const QUIPS: Record<QuipKey, string[]> = {
|
||||
export const ALL_QUIP_KEYS: QuipKey[] = [
|
||||
// Pulses (event-driven, transient bubbles)
|
||||
session_done: [
|
||||
"a session just wrapped up! 🐾",
|
||||
"a session finished - nice work! ✨",
|
||||
"that session's all done 😺",
|
||||
"clean finish on that one 💜",
|
||||
],
|
||||
session_start: [
|
||||
"a new session started! 👀",
|
||||
"a fresh session just landed 🐾",
|
||||
"ooh, a new session to watch 😻",
|
||||
"something new is cooking 🍲",
|
||||
],
|
||||
subagent_spawn: [
|
||||
"a subagent just spawned! 🐾",
|
||||
"a little helper joined in 🤝",
|
||||
"a subagent's on the job 🚀",
|
||||
"reinforcements - new subagent! 😺",
|
||||
],
|
||||
waiting: [
|
||||
"a session needs your input 👀",
|
||||
"a session is waiting on you ⏳",
|
||||
"a session paused for your reply 💬",
|
||||
"your turn - a session's waiting 🐾",
|
||||
],
|
||||
error: [
|
||||
"uh oh, a session hit an error 😿",
|
||||
"something broke - wanna peek? 🙀",
|
||||
"a hook tripped on something ⚠️",
|
||||
"hiss… an error popped up 💢",
|
||||
],
|
||||
run_done: [
|
||||
"your run just finished! 🐾",
|
||||
"the run's all wrapped up ✨",
|
||||
"run complete - that's a wrap 😸",
|
||||
"all done with that run 💜",
|
||||
],
|
||||
// Moods (steady-state flavor, used by the panel / idle bubbles)
|
||||
disconnected: [
|
||||
"lost the connection… 😴",
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤",
|
||||
],
|
||||
worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
stuck: [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾",
|
||||
],
|
||||
happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"],
|
||||
};
|
||||
"session_done",
|
||||
"session_start",
|
||||
"subagent_spawn",
|
||||
"waiting",
|
||||
"error",
|
||||
"run_done",
|
||||
// Moods (steady-state flavor)
|
||||
"disconnected",
|
||||
"worried",
|
||||
"stuck",
|
||||
"happy",
|
||||
"thinking",
|
||||
"watching",
|
||||
"sleeping",
|
||||
"idle",
|
||||
];
|
||||
const KNOWN = new Set<string>(ALL_QUIP_KEYS);
|
||||
|
||||
/**
|
||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
||||
* Pick one line from an i18n string array under `settings:sagi.<path>`.
|
||||
* `rand` is injectable for deterministic tests. Returns "" for a missing or
|
||||
* empty pool (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
const pool = QUIPS[key];
|
||||
if (!pool || pool.length === 0) return "";
|
||||
export function pickLine(
|
||||
path: string,
|
||||
rand: () => number = Math.random,
|
||||
vars?: Record<string, unknown>
|
||||
): string {
|
||||
const pool = i18n.t(`settings:sagi.${path}`, { returnObjects: true, ...vars }) as unknown;
|
||||
if (!Array.isArray(pool) || pool.length === 0) return "";
|
||||
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
|
||||
return pool[i] ?? "";
|
||||
const line = pool[i];
|
||||
return typeof line === "string" ? line : "";
|
||||
}
|
||||
|
||||
export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[];
|
||||
/**
|
||||
* Pick a quip for a key, in the UI language. Returns "" only for an unknown
|
||||
* key (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
return KNOWN.has(key) ? pickLine(`quips.${key}`, rand) : "";
|
||||
}
|
||||
|
||||
/** Which greeting pool fits the hour: morning 5–11, afternoon 12–17, evening 18–22, else night. */
|
||||
export function greetingKey(hour: number): "morning" | "afternoon" | "evening" | "night" {
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 18) return "afternoon";
|
||||
if (hour >= 18 && hour < 23) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* sagi.css - animations + mood expressions for the Sagi companion.
|
||||
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
|
||||
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
|
||||
* forever: each plays a few cycles when the mood begins and then rests — an
|
||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||
* which made the whole desktop stutter. All motion is disabled under
|
||||
* [data-reduced="1"] and prefers-reduced-motion.
|
||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -206,20 +210,20 @@
|
||||
|
||||
/* idle: gentle breathing + occasional blink */
|
||||
.sagi-avatar[data-mood="idle"] {
|
||||
animation: sagi-breathe 4s ease-in-out infinite;
|
||||
animation: sagi-breathe 4s ease-in-out 0s 3;
|
||||
}
|
||||
/* Blink lives on the inner group so it never overrides the outer group's
|
||||
eye-tracking translate (which would freeze the eyes - see the CSS-blink
|
||||
note in SagiAvatar.tsx). */
|
||||
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
|
||||
animation: sagi-blink 5s ease-in-out infinite;
|
||||
animation: sagi-blink 5s ease-in-out 0s 3;
|
||||
}
|
||||
/* happy: whole-mascot bob + twinkling sparkle */
|
||||
.sagi-avatar[data-mood="happy"] {
|
||||
animation: sagi-bob 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
|
||||
}
|
||||
/* worried: alarmed shake */
|
||||
@@ -228,19 +232,97 @@
|
||||
}
|
||||
/* stuck: slow, drooping breathe (fatigued) */
|
||||
.sagi-avatar[data-mood="stuck"] {
|
||||
animation: sagi-droop 2.4s ease-in-out infinite;
|
||||
animation: sagi-droop 2.4s ease-in-out 0s 3;
|
||||
}
|
||||
/* thinking: rocking head tilt */
|
||||
.sagi-avatar[data-mood="thinking"] {
|
||||
animation: sagi-rock 1.8s ease-in-out infinite;
|
||||
animation: sagi-rock 1.8s ease-in-out 0s 4;
|
||||
}
|
||||
/* watching: attentive sway while a session is live */
|
||||
.sagi-avatar[data-mood="watching"] {
|
||||
animation: sagi-sway 3.2s ease-in-out infinite;
|
||||
animation: sagi-sway 3.2s ease-in-out 0s 3;
|
||||
}
|
||||
/* sleeping: slow breathe, droop */
|
||||
.sagi-avatar[data-mood="sleeping"] {
|
||||
animation: sagi-breathe 5s ease-in-out infinite;
|
||||
animation: sagi-breathe 5s ease-in-out 0s 3;
|
||||
}
|
||||
|
||||
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
|
||||
over the mood for ~2.4 s. Declared after the moods so they win the cascade.
|
||||
Shared by Sagi's own art and an uploaded still image. ── */
|
||||
.sagi-avatar[data-moment="jump"],
|
||||
.sagi-pet-image[data-moment="jump"] {
|
||||
animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3;
|
||||
}
|
||||
.sagi-avatar[data-moment="wave"],
|
||||
.sagi-pet-image[data-moment="wave"] {
|
||||
animation: sagi-wiggle 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-moment="fail"],
|
||||
.sagi-pet-image[data-moment="fail"] {
|
||||
animation: sagi-shake 0.35s ease-in-out 0s 5;
|
||||
}
|
||||
.sagi-avatar[data-moment="wait"],
|
||||
.sagi-pet-image[data-moment="wait"] {
|
||||
animation: sagi-tap 0.4s ease-in-out 0s 5;
|
||||
}
|
||||
.sagi-avatar[data-moment="review"],
|
||||
.sagi-pet-image[data-moment="review"] {
|
||||
animation: sagi-peek 2.2s ease-in-out 0s 1;
|
||||
}
|
||||
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px;
|
||||
}
|
||||
@keyframes sagi-hop {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1, 1);
|
||||
}
|
||||
15% {
|
||||
transform: translateY(0) scale(1.08, 0.92);
|
||||
}
|
||||
45% {
|
||||
transform: translateY(-14%) scale(0.96, 1.05);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(0) scale(1.04, 0.96);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-9deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-tap {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4%);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-peek {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) translateX(0);
|
||||
}
|
||||
25%,
|
||||
40% {
|
||||
transform: rotate(-7deg) translateX(-4%);
|
||||
}
|
||||
60%,
|
||||
75% {
|
||||
transform: rotate(7deg) translateX(4%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── reduced motion: kill all continuous animation ── */
|
||||
@@ -339,7 +421,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out infinite;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out 0s 5;
|
||||
}
|
||||
/* Sessions waiting on the user, when none errored: the same dot, amber. */
|
||||
.sagi-error-dot[data-kind="waiting"] {
|
||||
background: #f59e0b;
|
||||
animation: none;
|
||||
}
|
||||
.sagi-pet-image {
|
||||
transform-origin: 50% 92%;
|
||||
}
|
||||
.sagi-bubble {
|
||||
max-width: 16rem;
|
||||
@@ -356,6 +446,41 @@
|
||||
.sagi-bubble-enter {
|
||||
animation: sagi-bubble-in 0.22s ease-out;
|
||||
}
|
||||
.sagi-bubble[data-tone="error"] {
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
.sagi-bubble[data-tone="waiting"] {
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
}
|
||||
/* The session(s) a bubble is about, and the button that opens it. */
|
||||
.sagi-bubble-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: #a8a8c0;
|
||||
}
|
||||
.sagi-bubble-subject {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sagi-bubble-open {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #c4b5fd;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sagi-bubble-open:hover {
|
||||
background: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
@keyframes sagi-bubble-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -375,7 +500,7 @@
|
||||
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
||||
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
||||
.sagi-pet-image {
|
||||
animation: sagi-pet-bob 2.6s ease-in-out infinite;
|
||||
animation: sagi-pet-bob 2.6s ease-in-out 0s 3;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.sagi-pet-image[data-mood="thinking"] {
|
||||
@@ -403,3 +528,165 @@
|
||||
.sagi-flyout[data-front="1"] {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
||||
|
||||
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
||||
colour: a tint of the accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. Replies sit on the plain surface like
|
||||
the reference's white bubbles; the user's own are accent-solid (white text
|
||||
is only ever on accent-solid). */
|
||||
.pet-chat {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%,
|
||||
color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%,
|
||||
rgb(var(--surface-1)) 100%
|
||||
);
|
||||
}
|
||||
.pet-chat-head {
|
||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||
}
|
||||
.pet-chat-reply {
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
||||
}
|
||||
.pet-chat-tools {
|
||||
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
||||
}
|
||||
.pet-chat-foot {
|
||||
background: rgb(var(--surface-0));
|
||||
border-top: 1px solid rgb(var(--border) / 0.6);
|
||||
}
|
||||
/* Three bouncing dots while a reply is written. */
|
||||
.pet-chat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--accent));
|
||||
opacity: 0.5;
|
||||
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
.pet-chat-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.pet-chat-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes pet-chat-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
||||
does not shrink the pane to fit it) and a non-zero box (so the paste
|
||||
registry counts it as on screen). */
|
||||
.pet-chat-term {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 760px;
|
||||
height: 460px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-chat-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||
|
||||
/* Hearts float up from the pet and its line shows beside it, inside the
|
||||
avatar button — so a pat reads even while the panel or the chat is open
|
||||
(the speech bubble hides then). */
|
||||
.sagi-reaction {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sagi-hearts {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 10%;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.sagi-hearts > span {
|
||||
animation: sagi-heart-up 1.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.sagi-hearts > span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
font-size: 18px;
|
||||
}
|
||||
.sagi-hearts > span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes sagi-heart-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.6);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-4px) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-34px) scale(0.9);
|
||||
}
|
||||
}
|
||||
/* The line sits on the side away from the screen edge the pet docks to. */
|
||||
.sagi-reaction-text {
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
white-space: nowrap;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent);
|
||||
color: rgb(var(--fg-primary));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1rem;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
animation: sagi-reaction-in 2.4s ease-out forwards;
|
||||
}
|
||||
.sagi-reaction-text[data-side="right"] {
|
||||
right: calc(100% + 6px);
|
||||
}
|
||||
.sagi-reaction-text[data-side="left"] {
|
||||
left: calc(100% + 6px);
|
||||
}
|
||||
@keyframes sagi-reaction-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
10%,
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sagi-hearts > span,
|
||||
.sagi-reaction-text {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function usePets() {
|
||||
}
|
||||
};
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind, name: p.displayName });
|
||||
const install = (featuredId: string) =>
|
||||
run(featuredId, async () => {
|
||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
* @file useSagiBrain.ts
|
||||
* @description React hook that wires the pure Sagi brain to the live event bus
|
||||
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
||||
* exposes the derived mood, a status summary, the current speech bubble, and
|
||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
||||
* exposes the derived mood, a status summary, the sessions that need the user,
|
||||
* the current speech bubble (naming the session it is about, and coalescing a
|
||||
* burst of the same event into one count), the pet's one-shot animation
|
||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||
* for the UI shell. It also greets once per browser session and makes an
|
||||
* idle pet fidget now and then.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -62,127 +66,303 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { api } from "../../lib/api";
|
||||
import i18n from "../../i18n";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
import {
|
||||
initialSagiState,
|
||||
reduceSagi,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
attentionOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
subjectLabel,
|
||||
MOMENT_OF_PULSE,
|
||||
type AttentionItem,
|
||||
type Mood,
|
||||
type PetMoment,
|
||||
type SagiPulse,
|
||||
type SagiState,
|
||||
type SagiStatus,
|
||||
type SagiSubject,
|
||||
} from "./brain";
|
||||
import { pickQuip } from "./quips";
|
||||
import { greetingKey, pickLine, pickQuip } from "./quips";
|
||||
import { statusSummary } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
/** `next` differs from `prev` only by a lastActivityAt less than a second
|
||||
* newer — nothing the mascot shows, at the moods' 1 s resolution. */
|
||||
function onlyActivityMoved(prev: SagiState, next: SagiState): boolean {
|
||||
if (next === prev) return true;
|
||||
if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false;
|
||||
for (const k of Object.keys(next) as (keyof SagiState)[]) {
|
||||
if (k !== "lastActivityAt" && next[k] !== prev[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const BUBBLE_MS = 4500;
|
||||
// A bubble that names a session stays long enough to click it.
|
||||
const SUBJECT_BUBBLE_MS = 7000;
|
||||
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||
const BUBBLE_THROTTLE_MS = 3000;
|
||||
// The same pulse again within this window joins the bubble on show as a count.
|
||||
const COALESCE_MS = 8000;
|
||||
// How long a moment (wave, hop, flinch…) plays over the mood.
|
||||
const MOMENT_MS = 2400;
|
||||
// An idle pet fidgets (waves or looks around) once in a while.
|
||||
const FIDGET_MIN_MS = 45_000;
|
||||
const FIDGET_SPAN_MS = 60_000;
|
||||
// Greet once per browser session, shortly after the snapshot lands.
|
||||
const GREETED_KEY = "agenthub.pet.greeted";
|
||||
const GREET_DELAY_MS = 1200;
|
||||
|
||||
/** What the speech bubble shows. */
|
||||
export interface SagiBubble {
|
||||
text: string;
|
||||
/** Which session(s) it is about, shown under the text. */
|
||||
detail?: string;
|
||||
/** The one session it is about, which a click opens. */
|
||||
subject?: SagiSubject;
|
||||
/** Errors and waits are drawn with an accent. */
|
||||
tone?: "error" | "waiting";
|
||||
}
|
||||
|
||||
export interface SagiBrain {
|
||||
mood: Mood;
|
||||
status: SagiStatus;
|
||||
bubble: string | null;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: AttentionItem[];
|
||||
bubble: SagiBubble | null;
|
||||
dismissBubble: () => void;
|
||||
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
||||
* errors). Bypasses the throttle because the caller decided this message
|
||||
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
||||
setBubble: (text: string) => void;
|
||||
/** The one-shot animation playing now, if any; `key` changes per moment so a
|
||||
* repeat of the same one restarts it. */
|
||||
moment: { kind: PetMoment; key: number } | null;
|
||||
/** Play a moment by hand (an answer, a pat). */
|
||||
playMoment: (kind: PetMoment) => void;
|
||||
/** Pat the pet: a hop, hearts and a happy line right on the pet — shown
|
||||
* even while the panel or chat is open, and while muted (it was asked). */
|
||||
pat: () => void;
|
||||
/** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */
|
||||
reaction: { text: string; key: number } | null;
|
||||
muted: boolean;
|
||||
toggleMute: () => void;
|
||||
clearAlerts: () => void;
|
||||
setThinking: (v: boolean) => void;
|
||||
}
|
||||
|
||||
type Burst = { pulse: NonNullable<SagiPulse>; count: number; subjects: SagiSubject[]; at: number };
|
||||
|
||||
function burstBubble(b: Burst): SagiBubble {
|
||||
const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined;
|
||||
const one = b.subjects.length === 1 ? b.subjects[0] : undefined;
|
||||
const labels = b.subjects.map(subjectLabel);
|
||||
const detail =
|
||||
labels.length > 2
|
||||
? i18n.t("settings:sagi.bubbleMore", {
|
||||
list: labels.slice(0, 2).join(", "),
|
||||
count: labels.length - 2,
|
||||
})
|
||||
: labels.join(", ") || undefined;
|
||||
const text =
|
||||
b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse);
|
||||
return { text, detail, subject: one, tone };
|
||||
}
|
||||
|
||||
export function useSagiBrain(): SagiBrain {
|
||||
const now0 = Date.now();
|
||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
||||
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||
const [state, setState] = useState<SagiState>(() => ({
|
||||
const [state, setStateRaw] = useState<SagiState>(() => ({
|
||||
...initialSagiState(now0),
|
||||
connected: true,
|
||||
dismissed: sagiPrefs.getDismissed(),
|
||||
}));
|
||||
// The reducer runs OUTSIDE React's updater (which StrictMode calls twice),
|
||||
// so a pulse notifies exactly once; this ref is the state it folds into.
|
||||
const stateRef = useRef(state);
|
||||
const update = useCallback((fn: (prev: SagiState) => SagiState) => {
|
||||
const next = fn(stateRef.current);
|
||||
if (next === stateRef.current) return;
|
||||
stateRef.current = next;
|
||||
setStateRaw(next);
|
||||
}, []);
|
||||
|
||||
const [tick, setTick] = useState(now0);
|
||||
const [bubble, setBubbleState] = useState<string | null>(null);
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
const mutedRef = useRef(muted);
|
||||
mutedRef.current = muted;
|
||||
|
||||
// Keep cleared alerts cleared across reloads.
|
||||
useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]);
|
||||
|
||||
// Keep mute in sync with the Settings page / other tabs.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
||||
|
||||
const showBubble = useCallback((text: string, force: boolean) => {
|
||||
if (!text) return;
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
setBubbleState(b);
|
||||
bubbleTimer.current = setTimeout(() => {
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, ms);
|
||||
}, []);
|
||||
|
||||
const playMoment = useCallback((kind: PetMoment) => {
|
||||
clearTimeout(momentTimer.current);
|
||||
setMoment({ kind, key: ++momentKey.current });
|
||||
momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS);
|
||||
}, []);
|
||||
|
||||
const notify = useCallback(
|
||||
(pulse: NonNullable<SagiPulse>, subject?: SagiSubject) => {
|
||||
playMoment(MOMENT_OF_PULSE[pulse]);
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
const b = burst.current;
|
||||
// The same kind of news again, while its bubble is still up: count it in.
|
||||
if (b && b.pulse === pulse && t - b.at < COALESCE_MS) {
|
||||
b.count++;
|
||||
b.at = t;
|
||||
if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject);
|
||||
putBubble(burstBubble(b), SUBJECT_BUBBLE_MS);
|
||||
return;
|
||||
}
|
||||
const force = pulse === "error" || pulse === "waiting";
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t };
|
||||
burst.current = next;
|
||||
putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS);
|
||||
},
|
||||
[playMoment, putBubble]
|
||||
);
|
||||
|
||||
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||
// the brain otherwise only learns about sessions from WS deltas that arrive
|
||||
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
||||
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||
// Then say hello, once per browser session.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let greetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const greet = () => {
|
||||
try {
|
||||
if (sessionStorage.getItem(GREETED_KEY)) return;
|
||||
sessionStorage.setItem(GREETED_KEY, "1");
|
||||
} catch {
|
||||
return; // No storage: rather silent than a hello on every page.
|
||||
}
|
||||
greetTimer = setTimeout(() => {
|
||||
if (cancelled || mutedRef.current) return;
|
||||
const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`);
|
||||
if (!hello) return;
|
||||
lastBubbleAt.current = Date.now();
|
||||
putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) });
|
||||
playMoment("wave");
|
||||
}, GREET_DELAY_MS);
|
||||
};
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => undefined)
|
||||
.finally(greet);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(greetTimer);
|
||||
};
|
||||
}, []);
|
||||
}, [update, putBubble, playMoment]);
|
||||
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
const t = Date.now();
|
||||
setState((prev) => {
|
||||
const { state: next, pulse } = reduceSagi(prev, msg, t);
|
||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
||||
return next;
|
||||
});
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
// resolution: keep the old state then, so a hook burst (several frames a
|
||||
// second) does not re-render the mascot on every frame.
|
||||
if (!onlyActivityMoved(prev, next)) update(() => next);
|
||||
if (pulse) notify(pulse, subject);
|
||||
});
|
||||
const unsubConn = eventBus.onConnection((connected) => {
|
||||
setState((prev) => ({ ...prev, connected }));
|
||||
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
|
||||
});
|
||||
return () => {
|
||||
unsubMsg();
|
||||
unsubConn();
|
||||
};
|
||||
}, [showBubble]);
|
||||
}, [update, notify]);
|
||||
|
||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
useEffect(() => {
|
||||
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
||||
// Only a tick that changes the mood re-renders: an idle mascot is not
|
||||
// drawn again every second for nothing.
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") setTick(Date.now());
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const now = Date.now();
|
||||
if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now);
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
clearTimeout(momentTimer.current);
|
||||
clearTimeout(reactionTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
||||
// Judged at render time: `tick` only moves when the mood would change, so
|
||||
// it may be old when a frame re-renders.
|
||||
const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]);
|
||||
const status = useMemo(() => statusOf(state), [state]);
|
||||
const attention = useMemo(() => attentionOf(state), [state]);
|
||||
|
||||
// An idle pet is not a statue: every minute or so it waves or looks around.
|
||||
const moodRef = useRef(mood);
|
||||
moodRef.current = mood;
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const schedule = () => {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
if (moodRef.current === "idle" && document.visibilityState === "visible")
|
||||
playMoment(Math.random() < 0.5 ? "wave" : "review");
|
||||
schedule();
|
||||
},
|
||||
FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS
|
||||
);
|
||||
};
|
||||
schedule();
|
||||
return () => clearTimeout(timer);
|
||||
}, [playMoment]);
|
||||
|
||||
const dismissBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
@@ -192,29 +372,47 @@ export function useSagiBrain(): SagiBrain {
|
||||
if (next) dismissBubble();
|
||||
}, [dismissBubble]);
|
||||
|
||||
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
|
||||
const clearAlerts = useCallback(() => {
|
||||
update((prev) => clearErrors(prev));
|
||||
if (burst.current?.pulse === "error") dismissBubble();
|
||||
}, [update, dismissBubble]);
|
||||
|
||||
const setThinking = useCallback(
|
||||
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[]
|
||||
(v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[update]
|
||||
);
|
||||
|
||||
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
|
||||
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
||||
// bypassing the throttled mood-driven showBubble path.
|
||||
const setBubble = useCallback((text: string) => {
|
||||
if (!text) return;
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
}, []);
|
||||
const setBubble = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) return;
|
||||
burst.current = null;
|
||||
putBubble({ text });
|
||||
},
|
||||
[putBubble]
|
||||
);
|
||||
|
||||
const pat = useCallback(() => {
|
||||
playMoment("jump");
|
||||
update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS }));
|
||||
clearTimeout(reactionTimer.current);
|
||||
setReaction({ text: pickLine("pat"), key: ++momentKey.current });
|
||||
reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS);
|
||||
}, [playMoment, update]);
|
||||
|
||||
return {
|
||||
mood,
|
||||
status,
|
||||
attention,
|
||||
bubble,
|
||||
dismissBubble,
|
||||
setBubble,
|
||||
moment,
|
||||
playMoment,
|
||||
pat,
|
||||
reaction,
|
||||
muted,
|
||||
toggleMute,
|
||||
clearAlerts,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sidebar.tsx
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -49,12 +49,15 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { settingsHref } from "../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
Gem,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Wifi,
|
||||
@@ -70,12 +73,13 @@ import {
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
@@ -87,6 +91,10 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
const NAV_KEYS = [
|
||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
// The file manager (FilesPage): browse, upload and download workspace files.
|
||||
{ to: "/files", icon: HardDrive, key: "nav:files" },
|
||||
// What agents produced, and (server mode) the Hub others shared (Artifacts.tsx).
|
||||
{ to: "/artifacts", icon: Gem, key: "nav:artifacts" },
|
||||
] as const;
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
@@ -186,8 +194,15 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth();
|
||||
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
|
||||
// link's own match (the path) cannot tell when it is the one on screen.
|
||||
const location = useLocation();
|
||||
const settingsOn =
|
||||
location.pathname.startsWith("/settings") ||
|
||||
new URLSearchParams(location.search).has("settings");
|
||||
// Checking for (and applying) an update acts on the host: an admin's.
|
||||
const canUpdate = usePermissions().isAdmin;
|
||||
const perms = usePermissions();
|
||||
const canUpdate = perms.isAdmin;
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
@@ -462,12 +477,13 @@ export function Sidebar({
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||
{[
|
||||
...NAV_KEYS,
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
// The file manager serves a shared server; locally it is the desktop's.
|
||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||
// Server mode: Administration (users, server), for an admin or analyst.
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
auth.state.user.role !== "developer"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
@@ -525,6 +541,59 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server mode: who is signed in — avatar, name and role, opening
|
||||
Settings → Account — and Log out. On the rail they stack. */}
|
||||
{auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
(() => {
|
||||
const user = auth.state.user;
|
||||
const name = user.displayName || user.username;
|
||||
const role = t(`users:roles.${user.role}`);
|
||||
const signedIn = t("common:auth.signedInAs", { name, role });
|
||||
return (
|
||||
<div
|
||||
data-testid="sidebar-account"
|
||||
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
|
||||
collapsed ? "flex-col items-center" : "items-center"
|
||||
}`}
|
||||
>
|
||||
<NavLink
|
||||
to={settingsHref("account", location)}
|
||||
title={signedIn}
|
||||
aria-label={signedIn}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-md transition-colors hover:bg-surface-3 ${
|
||||
collapsed ? "justify-center p-1" : "flex-1 px-1.5 py-1"
|
||||
}`}
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={user.avatarAt}
|
||||
className={collapsed ? "h-7 w-7 text-[11px]" : "h-8 w-8 text-xs"}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span className="min-w-0 leading-tight">
|
||||
<span className="block truncate text-sm font-medium text-fg-primary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-fg-muted">{role}</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void auth.logout()}
|
||||
title={`${signedIn} — ${t("common:auth.logout")}`}
|
||||
aria-label={t("common:auth.logout")}
|
||||
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "w-full py-2" : "h-8 w-8 flex-shrink-0"
|
||||
}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Settings and the collapse toggle stick to the foot, out of the
|
||||
scrolling nav. On the rail they stack. */}
|
||||
<div
|
||||
@@ -533,29 +602,13 @@ export function Sidebar({
|
||||
}`}
|
||||
>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
to={settingsHref("", location)}
|
||||
title={collapsed ? t("nav:settings") : undefined}
|
||||
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
|
||||
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||
</NavLink>
|
||||
{auth.state.mode === "server" && auth.state.user && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void auth.logout()}
|
||||
title={`${t("common:auth.signedInAs", {
|
||||
name: auth.state.user.username,
|
||||
role: auth.state.user.role,
|
||||
})} — ${t("common:auth.logout")}`}
|
||||
aria-label={t("common:auth.logout")}
|
||||
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
|
||||
}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file SplashScreen.tsx
|
||||
* @description Branding splash shown once per browser session on app load. A
|
||||
* dark-tech "constellation" overlay built around the node-graph brand mark:
|
||||
* dark-tech "constellation" overlay built around the SmartGift brand mark:
|
||||
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
|
||||
* in a staggered cascade. The tagline and the subtext pair are picked at
|
||||
* random (per mount) from localized pools in `splash.json`, so the copy is
|
||||
@@ -143,7 +143,7 @@ export function SplashScreen() {
|
||||
<div className="splash-grain" aria-hidden="true" />
|
||||
|
||||
<div className="splash-content">
|
||||
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
|
||||
{/* Brand mark - the SmartGift logo */}
|
||||
<div className="splash-mark" aria-hidden="true">
|
||||
<span className="splash-mark-glow" />
|
||||
<BrandMark />
|
||||
@@ -167,74 +167,10 @@ export function SplashScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
|
||||
* with animated connector lines and pulsing outer nodes. */
|
||||
/** The SmartGift mark — the same file as the favicon, local so the splash
|
||||
* needs no network. */
|
||||
function BrandMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
|
||||
<defs>
|
||||
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6366f1" />
|
||||
<stop offset="100%" stopColor="#818cf8" />
|
||||
</linearGradient>
|
||||
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
className="splash-hex"
|
||||
points="16,2 28,9 28,23 16,30 4,23 4,9"
|
||||
fill="url(#splashBg)"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
|
||||
<line
|
||||
className="splash-line"
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="16"
|
||||
y2="7"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-2"
|
||||
x1="18.6"
|
||||
y1="17.5"
|
||||
x2="24"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-3"
|
||||
x1="13.4"
|
||||
y1="17.5"
|
||||
x2="8"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
|
||||
<circle
|
||||
className="splash-node splash-node-2"
|
||||
cx="24.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
<circle
|
||||
className="splash-node splash-node-3"
|
||||
cx="7.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
|
||||
}
|
||||
|
||||
/** Faint background constellation: a handful of nodes joined by thin lines that
|
||||
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
|
||||
animation: splashGlowPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
|
||||
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
|
||||
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
|
||||
.splash-line-2 { animation-delay: 0.62s; }
|
||||
.splash-line-3 { animation-delay: 0.74s; }
|
||||
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
|
||||
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
|
||||
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
|
||||
|
||||
.splash-greeting {
|
||||
display: flex;
|
||||
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
|
||||
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
|
||||
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
|
||||
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
|
||||
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
|
||||
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
|
||||
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
|
||||
.splash-star,
|
||||
.splash-mark,
|
||||
.splash-mark-glow,
|
||||
.splash-hex,
|
||||
.splash-line,
|
||||
.splash-node,
|
||||
.splash-dot,
|
||||
.splash-greeting,
|
||||
.splash-tagline,
|
||||
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
opacity: 1 !important;
|
||||
stroke-dashoffset: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it. Escape, the backdrop and "Not now" close it
|
||||
* for this page load only (never while applying); "Skip this version" is the
|
||||
* one persistent, per-version dismiss.
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only (never while applying); "Skip this version" is the one
|
||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||
* and starts a Claude Code session there with the error as its prompt — the
|
||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||
* and runs the update command; the dashboard itself still decides nothing.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -81,7 +85,18 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Download,
|
||||
X,
|
||||
Copy,
|
||||
Check,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
ExternalLink,
|
||||
SkipForward,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -89,6 +104,7 @@ import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
import { startInFreeTab } from "./lanes/startSession";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
@@ -107,6 +123,32 @@ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean
|
||||
);
|
||||
}
|
||||
|
||||
/** The prompt the Auto fix session starts with: what failed, where, and the
|
||||
* limits that keep the agent from taking down the dashboard it runs under. */
|
||||
function autoFixPrompt(status: UpdateStatusPayload, error: string): string {
|
||||
const target = status.latest_release || status.remote_ref || "the newest version";
|
||||
const plugin = status.situation === "plugin_managed";
|
||||
return [
|
||||
`The AgentHub dashboard's self-update to ${target} failed${status.local_version ? ` (running ${status.local_version})` : ""}.`,
|
||||
plugin
|
||||
? "AgentHub is installed as a Claude Code plugin (the `agenthub` plugin from the `agenthub` marketplace)."
|
||||
: `The install is the git checkout at ${status.repo_root}.`,
|
||||
"",
|
||||
"Error:",
|
||||
"```",
|
||||
error,
|
||||
"```",
|
||||
...(status.manual_command ? ["", "Update command:", "```", status.manual_command, "```"] : []),
|
||||
"",
|
||||
"Find the cause, fix it, then finish the update" +
|
||||
(plugin
|
||||
? " (`claude plugin marketplace update agenthub` then `claude plugin update agenthub@agenthub`)."
|
||||
: " by running the update command."),
|
||||
"Rules: never lose the user's work — before discarding any local change, save it (e.g. `git diff > ~/agenthub-update-backup.patch`) and say where; do not stop or restart the AgentHub server, and never kill tmux sessions or the tmux server (this session runs inside one).",
|
||||
"When done, tell me what you changed; I will restart the dashboard from its update prompt.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||
@@ -130,7 +172,7 @@ export function UpdateNotifier() {
|
||||
const { t } = useTranslation("updates");
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
|
||||
// Closed for THIS page load only (Escape, backdrop, ×) — the
|
||||
// persistent per-version dismiss is the explicit "Skip this version".
|
||||
const [closedKey, setClosedKey] = useState<string | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -139,6 +181,8 @@ export function UpdateNotifier() {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
setStatus(s);
|
||||
@@ -200,7 +244,7 @@ export function UpdateNotifier() {
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
);
|
||||
const busy = applying || restarting;
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
@@ -317,6 +361,29 @@ export function UpdateNotifier() {
|
||||
}
|
||||
};
|
||||
|
||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||
// home for a plugin install — never a lane inside the plugin cache) and a
|
||||
// Claude Code session there with the error as its first prompt.
|
||||
const autoFix = async () => {
|
||||
if (busy || !status || !error) return;
|
||||
setFixing(true);
|
||||
try {
|
||||
const plugin = status.situation === "plugin_managed";
|
||||
const { lane } = await api.lanes.ensure({
|
||||
...(plugin || !status.repo_root ? {} : { cwd: status.repo_root, exact: true }),
|
||||
title: t("autoFixWorkspace"),
|
||||
tool: "claude",
|
||||
});
|
||||
const runId = await startInFreeTab(lane.id, { prompt: autoFixPrompt(status, error) });
|
||||
if (dismissKey) setClosedKey(dismissKey);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("autoFixError"));
|
||||
} finally {
|
||||
setFixing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!show || !status) return null;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -493,16 +560,9 @@ export function UpdateNotifier() {
|
||||
title={t("skipVersionHint")}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" aria-hidden />
|
||||
{t("skipVersion")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("later")}
|
||||
</button>
|
||||
{status.manual_command ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -514,11 +574,28 @@ export function UpdateNotifier() {
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
) : null}
|
||||
{/* Only after a failed update: an AI agent takes the error from here. */}
|
||||
{error ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={autoFix}
|
||||
disabled={busy}
|
||||
title={t("autoFixHint")}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{fixing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Wrench className="w-3.5 h-3.5" aria-hidden />
|
||||
)}
|
||||
{t("autoFix")}
|
||||
</button>
|
||||
) : null}
|
||||
{isAutoApplicable(status.situation) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyNow}
|
||||
disabled={applying || restarting}
|
||||
disabled={busy}
|
||||
className="btn-primary disabled:opacity-70"
|
||||
>
|
||||
{applying || restarting ? (
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
|
||||
* analyst: EVERY workspace, grouped under its owner (`owner_name`; yours
|
||||
* analyst: EVERY workspace, grouped under its owner (their avatar —
|
||||
* `owner_avatar_at`, `UserAvatar` — and `owner_display_name`, else `owner_name`; yours
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
@@ -48,6 +49,7 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
|
||||
// The sidebar is in the entry chunk; its two modals (and the run form the add
|
||||
// modal pulls in) load when first opened.
|
||||
@@ -60,6 +62,9 @@ const DestructiveLaneModal = lazy(() =>
|
||||
|
||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
||||
/** An owner as the tree shows it: their display name, else their username. */
|
||||
const ownerShown = (l: Lane): string => l.owner_display_name || l.owner_name || "";
|
||||
|
||||
export function selectedFrom(pathname: string, search: string) {
|
||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||
const p = new URLSearchParams(search);
|
||||
@@ -150,7 +155,7 @@ export function WorkspaceTree() {
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
|
||||
return [...list].sort(
|
||||
(a, b) => rank(a) - rank(b) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
|
||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||
);
|
||||
}, [lanes, order, toolFilter, grouped, meId]);
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -338,8 +343,8 @@ export function WorkspaceTree() {
|
||||
lane.owner_id == null
|
||||
? t("tree.noOwner")
|
||||
: lane.owner_id === meId
|
||||
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
|
||||
: (lane.owner_name ?? `#${lane.owner_id}`);
|
||||
? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` })
|
||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
return (
|
||||
@@ -362,12 +367,14 @@ export function WorkspaceTree() {
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{lane.owner_name?.[0] ?? "?"}
|
||||
</span>
|
||||
<UserAvatar
|
||||
name={ownerShown(lane) || "?"}
|
||||
userId={lane.owner_id ?? undefined}
|
||||
avatarAt={lane.owner_avatar_at}
|
||||
letters={1}
|
||||
className="h-4 w-4 text-[9px]"
|
||||
initialsClassName="bg-surface-4 text-fg-secondary"
|
||||
/>
|
||||
<span className="truncate">{ownerLabel}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">
|
||||
{sorted.filter((l) => ownerKey(l) === owner).length}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* @file PathPickButton.test.tsx
|
||||
* @description Unit tests for the Browse button beside path fields: a chosen
|
||||
* path fills the field, a Cancel changes nothing, a folder falls back to the
|
||||
* in-app browser on ANY native-picker failure, and a file falls back to a
|
||||
* "type the path" hint only when no picker exists for this browser.
|
||||
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
||||
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
||||
* EREMOTE included), where a double-click on a file picks it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { api } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } },
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
|
||||
render(<PathPickButton kind="file" onPick={vi.fn()} />);
|
||||
const button = screen.getByRole("button");
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
|
||||
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
|
||||
const onPick = vi.fn();
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/home/u/.ssh",
|
||||
parent: "/home/u",
|
||||
entries: [
|
||||
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
|
||||
],
|
||||
});
|
||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
|
||||
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
||||
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @file Sidebar.account.test.tsx
|
||||
* @description The sidebar's account row (server mode): the signed-in user's
|
||||
* avatar — or initials without one — name and role, linking to Settings →
|
||||
* Account, beside Sign out; local mode shows no row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
state: { mode: "local" } as Record<string, unknown>,
|
||||
logout: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("../AuthGate", () => ({
|
||||
useAuth: () => auth,
|
||||
usePermissions: () => ({
|
||||
isAdmin: auth.state.mode !== "server",
|
||||
local: auth.state.mode !== "server",
|
||||
seesAll: true,
|
||||
canEditLane: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { Sidebar } from "../Sidebar";
|
||||
|
||||
const renderSidebar = (collapsed = false) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Sidebar
|
||||
wsConnected
|
||||
collapsed={collapsed}
|
||||
onToggle={() => {}}
|
||||
connectionOpen={false}
|
||||
onConnectionOpenChange={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("Sidebar account row", () => {
|
||||
it("shows the avatar, name and role, linking to Settings → Account", () => {
|
||||
auth.state = {
|
||||
mode: "server",
|
||||
user: { id: 2, username: "mia", role: "developer", displayName: "Mia Tran", avatarAt: "t1" },
|
||||
};
|
||||
renderSidebar();
|
||||
const row = screen.getByTestId("sidebar-account");
|
||||
expect(row.textContent).toContain("Mia Tran");
|
||||
expect(row.textContent).toContain("Developer");
|
||||
expect(within(row).getByTestId("user-avatar-img").getAttribute("src")).toBe(
|
||||
"/api/me/avatar?v=t1"
|
||||
);
|
||||
expect(within(row).getAllByRole("link")[0]?.getAttribute("href")).toBe("/settings/account");
|
||||
expect(within(row).getByRole("button", { name: "Sign out" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to initials, and keeps only the avatar on the rail", () => {
|
||||
auth.state = { mode: "server", user: { id: 3, username: "leo", role: "admin" } };
|
||||
renderSidebar(true);
|
||||
const row = screen.getByTestId("sidebar-account");
|
||||
expect(within(row).getByTestId("user-avatar-initials").textContent).toBe("le");
|
||||
expect(row.textContent).not.toContain("Admin");
|
||||
});
|
||||
|
||||
it("has no account row in local mode", () => {
|
||||
auth.state = { mode: "local" };
|
||||
renderSidebar();
|
||||
expect(screen.queryByTestId("sidebar-account")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,8 @@ describe("Sidebar", () => {
|
||||
expect(hrefs).toContain("/sessions");
|
||||
expect(hrefs).toContain("/settings");
|
||||
expect(hrefs).not.toContain("/activity");
|
||||
// The file manager serves a shared server; a local dashboard has none.
|
||||
expect(hrefs).not.toContain("/files");
|
||||
});
|
||||
|
||||
describe("update status line, in the connection details modal", () => {
|
||||
|
||||
@@ -19,6 +19,18 @@ import type { UpdateStatusPayload } from "../../lib/types";
|
||||
const statusMock = vi.fn();
|
||||
const checkMock = vi.fn();
|
||||
const applyMock = vi.fn();
|
||||
const ensureMock = vi.fn();
|
||||
const startMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../lanes/startSession", () => ({
|
||||
startInFreeTab: (...args: unknown[]) => startMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -30,6 +42,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
check: (...args: unknown[]) => checkMock(...args),
|
||||
apply: (...args: unknown[]) => applyMock(...args),
|
||||
},
|
||||
lanes: { ensure: (...args: unknown[]) => ensureMock(...args) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -56,6 +69,9 @@ beforeEach(() => {
|
||||
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||
applyMock.mockReset();
|
||||
ensureMock.mockReset().mockResolvedValue({ lane: { id: 7 }, created: true });
|
||||
startMock.mockReset().mockResolvedValue("agenthub-lane-7-1");
|
||||
navigateMock.mockReset();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
@@ -274,7 +290,7 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
|
||||
it("Check for updates re-opens a modal closed with Later", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
|
||||
await userEvent.click(await screen.findByRole("button", { name: i18n.t("updates:later") }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
@@ -290,3 +306,26 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — Auto fix", () => {
|
||||
it("offers no Not now button, and Auto fix only once an update failed", async () => {
|
||||
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
|
||||
render(<UpdateNotifier />);
|
||||
await screen.findByText(i18n.t("updates:updateNow"));
|
||||
expect(screen.queryByText(i18n.t("updates:later"))).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(i18n.t("updates:autoFix"))).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText(i18n.t("updates:updateNow")));
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:autoFix")));
|
||||
|
||||
await waitFor(() => expect(navigateMock).toHaveBeenCalled());
|
||||
expect(ensureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/repo", exact: true, tool: "claude" })
|
||||
);
|
||||
const [laneId, body] = startMock.mock.calls[0] ?? [];
|
||||
expect(laneId).toBe(7);
|
||||
expect(body.prompt).toContain("git merge --ff-only v2.8.0");
|
||||
expect(body.prompt).toContain(BASE_STATUS.manual_command);
|
||||
expect(navigateMock).toHaveBeenCalledWith("/workspace?lane=7&run=agenthub-lane-7-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file WorkspaceTree.owners.test.tsx
|
||||
* @description Server mode, the sidebar workspace list for an analyst: every
|
||||
* user's workspaces, grouped under their owner (yours first, no owner last),
|
||||
* user's workspaces, grouped under their owner (with their avatar, else an
|
||||
* initial; yours first, no owner last),
|
||||
* groups folding and remembered; another user's workspace is read only — no
|
||||
* menu, no new session, and a session opens its analysis page, never a pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -30,7 +31,11 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
|
||||
}) as unknown as Lane;
|
||||
|
||||
const lanes = [
|
||||
lane(1, 3, "bob", "Bob app"),
|
||||
{
|
||||
...lane(1, 3, "bob", "Bob app"),
|
||||
owner_display_name: "Bob Nguyen",
|
||||
owner_avatar_at: "t1",
|
||||
} as Lane,
|
||||
lane(2, null, null, "Old"),
|
||||
lane(3, ME.id, "ana", "Mine"),
|
||||
lane(4, 1, "admin", "Admin site"),
|
||||
@@ -61,6 +66,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
},
|
||||
run: { list: vi.fn(async () => ({ items: [] })) },
|
||||
users: {
|
||||
avatarUrl: (id: number, at?: string | null) =>
|
||||
at ? `/api/users/${id}/avatar?v=${at}` : null,
|
||||
},
|
||||
me: { avatarUrl: () => null },
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
},
|
||||
};
|
||||
@@ -113,6 +123,23 @@ describe("WorkspaceTree for an analyst", () => {
|
||||
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
||||
});
|
||||
|
||||
it("names an owner by their display name when they set one", async () => {
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
|
||||
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
|
||||
// Bob set a picture: shown beside the name; Ana did not: her initial.
|
||||
expect(
|
||||
within(screen.getByTestId("workspace-owner-3"))
|
||||
.getByTestId("user-avatar-img")
|
||||
.getAttribute("src")
|
||||
).toBe("/api/users/3/avatar?v=t1");
|
||||
expect(
|
||||
within(screen.getByTestId("workspace-owner-2")).getByTestId("user-avatar-initials")
|
||||
).toHaveTextContent("a");
|
||||
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
|
||||
});
|
||||
|
||||
it("folds a group and remembers it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
|
||||
@@ -154,7 +154,12 @@ describe("WorkspaceTree", () => {
|
||||
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
||||
// Its OWN sessions only — never the rest of its folder.
|
||||
expect(api.sessions.list).toHaveBeenCalledTimes(1);
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 6,
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
||||
"1": true,
|
||||
});
|
||||
@@ -175,11 +180,21 @@ describe("WorkspaceTree", () => {
|
||||
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
|
||||
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
|
||||
await user.click(screen.getByTestId("workspace-sessions-more-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 9,
|
||||
});
|
||||
await screen.findByText("Session 8");
|
||||
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
||||
await user.click(screen.getByTestId("workspace-sessions-less-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 6,
|
||||
});
|
||||
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
||||
});
|
||||
@@ -197,6 +212,38 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
it("pins a session above the live ones, and unpins it from its row or menu", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [
|
||||
session("s-live", "Live one"),
|
||||
{ ...session("s-old", "Old one"), pinned_at: "2026-10-01T00:00:00Z" } as Session,
|
||||
],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const list = await screen.findByTestId("workspace-sessions-1");
|
||||
await screen.findByTestId("workspace-session-pinned-s-old");
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByTestId(/^workspace-session-s-/)
|
||||
.map((el) => el.dataset.testid)
|
||||
).toEqual(["workspace-session-s-old", "workspace-session-s-live"]);
|
||||
await user.click(screen.getByTestId("workspace-session-pin-s-old"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { pinned: false });
|
||||
await user.click(screen.getByTestId("workspace-session-pin-s-live"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { pinned: true });
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: screen.getByTestId("workspace-session-s-old"),
|
||||
});
|
||||
await user.click(within(screen.getByTestId("session-menu-s-old")).getByText("Unpin"));
|
||||
expect(api.sessions.patch).toHaveBeenLastCalledWith("s-old", { pinned: false });
|
||||
});
|
||||
|
||||
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
@@ -207,7 +254,7 @@ describe("WorkspaceTree", () => {
|
||||
let menu = screen.getByTestId("session-menu-s-old");
|
||||
// Ended: no pane to split or close.
|
||||
expect(within(menu).queryByText("Open in split")).toBeNull();
|
||||
expect(within(menu).queryByText("Close tab (stops its session)")).toBeNull();
|
||||
expect(within(menu).queryByText("Close tab (stops and archives its session)")).toBeNull();
|
||||
await user.click(within(menu).getByText("Rename"));
|
||||
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
|
||||
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument();
|
||||
@@ -224,11 +271,17 @@ describe("WorkspaceTree", () => {
|
||||
await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
await user.click(screen.getByTestId("session-menu-sleep"));
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: false });
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("session-menu-s-live")).getByText("Close tab (stops its session)")
|
||||
within(screen.getByTestId("session-menu-s-live")).getByText(
|
||||
"Close tab (stops and archives its session)"
|
||||
)
|
||||
);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
});
|
||||
|
||||
it("archiving a worktree session removes its worktree first, after asking", async () => {
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* @file ServerPanel.tsx
|
||||
* @description The Administration page's Server tab: the machine AgentHub
|
||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||
* machine, resident memory, process count, the busiest processes by name, and
|
||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||
* at once. Read-only — the only action here is the members' tmux card
|
||||
* (`MemberTmuxCard`, an admin's restart).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MonitorCog,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Names, pictures and home sizes: read once, they change slowly.
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
api.users
|
||||
.storage()
|
||||
.then((r) => setStorage(r.storage))
|
||||
.catch(() => setStorage({}));
|
||||
}, []);
|
||||
|
||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const loading = !stats && !error;
|
||||
const mem = stats?.memory;
|
||||
const toggle = (key: string) =>
|
||||
setOpen((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("server.hostGroup");
|
||||
if (g.kind === "system") return t("server.systemGroup");
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
return u?.displayName || g.username || "";
|
||||
};
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="server-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||
</h2>
|
||||
{stats && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||
{" · "}
|
||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("server.cpu")}
|
||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||
icon={Cpu}
|
||||
trend={
|
||||
stats
|
||||
? t("server.cores", {
|
||||
n: stats.cpu.cores,
|
||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
raw={stats?.cpu.model ?? undefined}
|
||||
help={t("server.cpuHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.memory")}
|
||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||
icon={MemoryStick}
|
||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.swap")}
|
||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||
icon={MonitorCog}
|
||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.homesDisk")}
|
||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||
icon={HardDrive}
|
||||
trend={
|
||||
homesDisk
|
||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||
: undefined
|
||||
}
|
||||
help={t("server.homesDiskHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</p>
|
||||
</div>
|
||||
{stats && !stats.usage ? (
|
||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.who")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||
{t("server.sampling")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{stats?.usage?.groups.map((g) => {
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||
const isOpen = open.has(g.key);
|
||||
const name = groupName(g);
|
||||
return (
|
||||
<Fragment key={g.key}>
|
||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
{g.top.length ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{g.kind === "user" ? (
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={u?.avatarAt}
|
||||
userId={g.userId}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||
{g.kind === "user" && u?.displayName && (
|
||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(g.memory)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||
</div>
|
||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{g.processes}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{home ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(home.bytes)}
|
||||
{homesDisk && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{homesDisk && (
|
||||
<Meter
|
||||
value={pct(home.bytes, homesDisk.total)}
|
||||
label={`${name} ${t("server.home")}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen &&
|
||||
g.top.map((p) => (
|
||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{fmtPct(p.cpu)}
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{formatBytes(p.memory)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.disks.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("server.disks")}
|
||||
</h3>
|
||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.mount")}</th>
|
||||
<th className={th}>{t("server.device")}</th>
|
||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{stats.disks.map((d) => (
|
||||
<tr key={d.device}>
|
||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||
<span className="font-mono">{d.mount}</span>
|
||||
{d.mount === stats.homesMount && (
|
||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||
<span className="font-mono">{d.device}</span> · {d.type}
|
||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||
</div>
|
||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{formatBytes(d.free)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.gpus.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||
{stats.gpus.map((g, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-fg-primary">{g.name}</span>
|
||||
<span className="text-fg-muted tabular-nums">
|
||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||
{formatBytes(g.memoryTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter value={g.utilization} label={g.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
/**
|
||||
* @file ArtifactAddPanels.tsx
|
||||
* @description The Artifacts page's Add dialogs for a skill, a plugin and an
|
||||
* uploaded file (server/lib/artifacts.js):
|
||||
* - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown
|
||||
* file that becomes SKILL.md, several files, a whole folder — picked or
|
||||
* dropped — or a .zip/.skill; an upload holding several skills makes one
|
||||
* artifact each), **Write** (name, description, instructions) and
|
||||
* **Existing** (a folder in the user's ~/.claude/skills or a workspace's
|
||||
* .claude/skills). The user names the skill; the name is previewed as the
|
||||
* folder and `/command` it installs as (`skillSlug`, the server's rule).
|
||||
* - `PluginAddPanel` — paste a marketplace link (a git https link, a forge
|
||||
* web link, `owner/repo` or a marketplace.json link), read its plugin list
|
||||
* and tick the ones to add; or type `name@marketplace` by hand, which is
|
||||
* also where a link the server cannot read (ssh, a private repo) ends up.
|
||||
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
|
||||
import { api, type MarketplaceInfo } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||
const label = "block text-[11px] font-medium text-fg-secondary";
|
||||
const hint = "text-[11px] text-fg-muted";
|
||||
|
||||
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
||||
export function skillSlug(text: string): string {
|
||||
return text
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/đ/g, "d")
|
||||
.replace(/Đ/g, "D")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/^[-._]+|-+$/g, "")
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
/** `field:` of a Markdown file's frontmatter, or "". */
|
||||
export function frontmatterField(text: string, name: string): string {
|
||||
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
||||
if (!fm) return "";
|
||||
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? "");
|
||||
return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2");
|
||||
}
|
||||
|
||||
type Picked = { file: File; path: string };
|
||||
|
||||
/** A small file's text ("" on failure), only to prefill the name/description. */
|
||||
function readText(file: File): Promise<string> {
|
||||
if (file.size > 1024 * 1024) return Promise.resolve("");
|
||||
return new Promise((ok) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => ok(typeof r.result === "string" ? r.result : "");
|
||||
r.onerror = () => ok("");
|
||||
r.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
|
||||
const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p);
|
||||
const isArchive = (p: string) => /\.(zip|skill)$/i.test(p);
|
||||
const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p);
|
||||
|
||||
/** Every file under what was dropped, folders walked (webkitGetAsEntry). */
|
||||
async function droppedFiles(e: DragEvent): Promise<Picked[]> {
|
||||
const items = Array.from(e.dataTransfer.items || []);
|
||||
const entries = items
|
||||
.map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null))
|
||||
.filter((x): x is FileSystemEntry => !!x);
|
||||
if (!entries.length) {
|
||||
return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name }));
|
||||
}
|
||||
const out: Picked[] = [];
|
||||
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((ok, fail) =>
|
||||
(entry as FileSystemFileEntry).file(ok, fail)
|
||||
);
|
||||
out.push({ file, path: prefix + entry.name });
|
||||
return;
|
||||
}
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
// readEntries answers in batches until an empty one.
|
||||
for (;;) {
|
||||
const batch = await new Promise<FileSystemEntry[]>((ok, fail) =>
|
||||
reader.readEntries(ok, fail)
|
||||
);
|
||||
if (!batch.length) break;
|
||||
for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
|
||||
}
|
||||
};
|
||||
for (const entry of entries) await walk(entry, "");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What an upload will make, guessed the way the server decides it. */
|
||||
function describeUpload(files: Picked[]): {
|
||||
kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md";
|
||||
count: number;
|
||||
folder: string;
|
||||
} {
|
||||
const real = files.filter((f) => !JUNK.test(f.path));
|
||||
if (!real.length) return { kind: "none", count: 0, folder: "" };
|
||||
if (real.length === 1 && isArchive(real[0]!.path))
|
||||
return { kind: "archive", count: 0, folder: "" };
|
||||
const roots = [
|
||||
...new Set(
|
||||
real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/"))
|
||||
),
|
||||
].sort((a, b) => a.length - b.length);
|
||||
const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`);
|
||||
const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
|
||||
if (!top.length) {
|
||||
return real.length === 1 && isText(real[0]!.path)
|
||||
? { kind: "single-md", count: 1, folder: "" }
|
||||
: { kind: "no-skill-md", count: 0, folder: "" };
|
||||
}
|
||||
if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" };
|
||||
return { kind: "many", count: top.length, folder: "" };
|
||||
}
|
||||
|
||||
function Tabs<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: T;
|
||||
options: { id: T; label: string }[];
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" className="flex rounded border border-border bg-surface-2 p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === o.id}
|
||||
onClick={() => onChange(o.id)}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs ${
|
||||
value === o.id
|
||||
? "bg-accent-solid text-white"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The name field with the folder/command it becomes. */
|
||||
function SkillNameField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
required,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const slug = skillSlug(value);
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<label className={label}>
|
||||
{t("skill.name")}
|
||||
{required ? " *" : ""}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder="release-notes"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{value.trim() && (
|
||||
<p className={hint} data-testid="skill-slug">
|
||||
{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skill ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SkillAddPanel({
|
||||
lanes,
|
||||
onAdded,
|
||||
onClose,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
onAdded: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [mode, setMode] = useState<"upload" | "write" | "existing">("upload");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [nameTouched, setNameTouched] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [descTouched, setDescTouched] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [files, setFiles] = useState<Picked[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const folderInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
// `webkitdirectory` is not in React's typings.
|
||||
useEffect(() => {
|
||||
folderInput.current?.setAttribute("webkitdirectory", "");
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "existing") return;
|
||||
api.artifacts
|
||||
.skills(laneId ?? undefined)
|
||||
.then((r) => setSkills(r.items))
|
||||
.catch(() => setSkills([]));
|
||||
}, [mode, laneId]);
|
||||
|
||||
const shape = useMemo(() => describeUpload(files), [files]);
|
||||
|
||||
// Fill the name and description from what was picked, unless typed.
|
||||
useEffect(() => {
|
||||
if (!files.length) return;
|
||||
const md =
|
||||
files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ??
|
||||
(shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined);
|
||||
const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
|
||||
let live = true;
|
||||
void (async () => {
|
||||
const text = md ? await readText(md.file) : "";
|
||||
if (!live) return;
|
||||
const fmName = frontmatterField(text, "name");
|
||||
const fmDesc = frontmatterField(text, "description");
|
||||
if (!nameTouched) {
|
||||
const guess =
|
||||
shape.kind === "single-md"
|
||||
? fmName || stem(md?.path ?? "")
|
||||
: shape.kind === "one"
|
||||
? fmName || shape.folder
|
||||
: shape.kind === "archive"
|
||||
? stem(files[0]!.path)
|
||||
: "";
|
||||
setName(guess);
|
||||
}
|
||||
if (!descTouched && fmDesc) setDescription(fmDesc);
|
||||
})();
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// Refill only when the pick changes, never on a typed name.
|
||||
}, [files, shape]);
|
||||
|
||||
const pick = (list: FileList | null) => {
|
||||
if (!list?.length) return;
|
||||
setFiles(
|
||||
Array.from(list).map((file) => ({
|
||||
file,
|
||||
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const run = async (fn: () => Promise<number>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const n = await fn();
|
||||
toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added"));
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const many = shape.kind === "many";
|
||||
const total = files.reduce((n, f) => n + f.file.size, 0);
|
||||
const meta = (
|
||||
<>
|
||||
<label className={label}>
|
||||
{t("skill.description")}
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
rows={2}
|
||||
value={description}
|
||||
disabled={many}
|
||||
placeholder={t("skill.descriptionHint")}
|
||||
onChange={(e) => {
|
||||
setDescTouched(true);
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
value={title}
|
||||
disabled={many}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ id: "upload", label: t("skill.tab.upload") },
|
||||
{ id: "write", label: t("skill.tab.write") },
|
||||
{ id: "existing", label: t("skill.tab.existing") },
|
||||
]}
|
||||
/>
|
||||
|
||||
{mode === "upload" && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void run(async () => {
|
||||
const r = await api.artifacts.uploadSkills(files, {
|
||||
name: many ? undefined : name.trim(),
|
||||
description: many ? undefined : description.trim(),
|
||||
title: many ? undefined : title.trim(),
|
||||
});
|
||||
return r.items.length;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="skill-drop"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void droppedFiles(e).then((list) => list.length && setFiles(list));
|
||||
}}
|
||||
className={`rounded-lg border border-dashed p-4 text-center ${
|
||||
dragging ? "border-accent bg-accent/10" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => folderInput.current?.click()}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="skill-files"
|
||||
onChange={(e) => {
|
||||
pick(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="skill-folder"
|
||||
onChange={(e) => {
|
||||
pick(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="rounded border border-border bg-surface-2 p-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<p className="flex-1 text-xs text-fg-primary" data-testid="skill-shape">
|
||||
{t(`skill.shape.${shape.kind}`, { count: shape.count })}
|
||||
</p>
|
||||
<span className={hint}>
|
||||
{t("files", { count: files.length })} · {Math.ceil(total / 1024)} KB
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("skill.clear")}
|
||||
className="text-fg-muted hover:text-fg-primary"
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setNameTouched(false);
|
||||
setDescTouched(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="max-h-28 overflow-auto font-mono text-[11px] text-fg-secondary">
|
||||
{files.slice(0, 200).map((f) => (
|
||||
<li
|
||||
key={f.path}
|
||||
className={`truncate ${JUNK.test(f.path) ? "line-through opacity-50" : ""}`}
|
||||
>
|
||||
{f.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{many ? (
|
||||
<p className={hint}>{t("skill.manyHint")}</p>
|
||||
) : (
|
||||
<SkillNameField
|
||||
value={name}
|
||||
onChange={(v) => {
|
||||
setNameTouched(true);
|
||||
setName(v);
|
||||
}}
|
||||
required={shape.kind === "single-md"}
|
||||
/>
|
||||
)}
|
||||
{meta}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={
|
||||
busy ||
|
||||
!files.length ||
|
||||
shape.kind === "no-skill-md" ||
|
||||
(shape.kind === "single-md" && !skillSlug(name))
|
||||
}
|
||||
>
|
||||
{busy ? t("skill.uploading") : t("form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === "write" && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void run(async () => {
|
||||
await api.artifacts.writeSkill({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
content,
|
||||
title: title.trim() || undefined,
|
||||
});
|
||||
return 1;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SkillNameField value={name} onChange={setName} required />
|
||||
<label className={label}>
|
||||
{t("skill.description")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
rows={2}
|
||||
value={description}
|
||||
placeholder={t("skill.descriptionHint")}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
{t("skill.content")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5 font-mono text-xs`}
|
||||
rows={10}
|
||||
value={content}
|
||||
placeholder={t("skill.contentHint")}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={
|
||||
busy ||
|
||||
!skillSlug(name) ||
|
||||
!content.trim() ||
|
||||
(!description.trim() && !frontmatterField(content, "description"))
|
||||
}
|
||||
>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === "existing" && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={field}
|
||||
aria-label={t("form.workspace")}
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">{t("form.pickWorkspace")}</option>
|
||||
{lanes.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.title || l.cwd}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{skills && skills.length === 0 && <p className={hint}>{t("form.noSkills")}</p>}
|
||||
{skills && skills.length > 0 && (
|
||||
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||||
{skills.map((s) => (
|
||||
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
await api.artifacts.addSkill({
|
||||
name: s.name,
|
||||
...(s.from === "workspace" && laneId ? { laneId } : {}),
|
||||
title: title || undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
return 1;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<input
|
||||
className={field}
|
||||
placeholder={t("form.title")}
|
||||
aria-label={t("form.title")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={field}
|
||||
rows={2}
|
||||
placeholder={t("form.description")}
|
||||
aria-label={t("form.description")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plugin ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MANUAL_CODES = new Set(["ENEEDLOGIN", "ESSHMARKET", "ENOTMARKET", "EMARKETFETCH"]);
|
||||
|
||||
export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onClose: () => void }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [link, setLink] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<MarketplaceInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [have, setHave] = useState<Set<string>>(new Set());
|
||||
const [manual, setManual] = useState(false);
|
||||
const [key, setKey] = useState("");
|
||||
const [market, setMarket] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.artifacts
|
||||
.list({ scope: "mine", kind: "plugin" })
|
||||
.then((r) => setHave(new Set(r.items.map((i) => i.source ?? ""))))
|
||||
.catch(() => setHave(new Set()));
|
||||
}, []);
|
||||
|
||||
const read = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const r = await api.artifacts.inspectMarket(link.trim());
|
||||
setInfo(r);
|
||||
setPicked(
|
||||
new Set(
|
||||
r.plugins
|
||||
.filter((p) => !have.has(p.key))
|
||||
.slice(0, 1)
|
||||
.map((p) => p.key)
|
||||
)
|
||||
);
|
||||
setMarket(r.market);
|
||||
} catch (err) {
|
||||
const e = err as Error & { code?: string };
|
||||
setError(e.message);
|
||||
if (MANUAL_CODES.has(e.code ?? "")) {
|
||||
setManual(true);
|
||||
setMarket(link.trim());
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addPicked = async () => {
|
||||
if (!info) return;
|
||||
setBusy(true);
|
||||
let n = 0;
|
||||
try {
|
||||
for (const p of info.plugins.filter((x) => picked.has(x.key))) {
|
||||
await api.artifacts.addPlugin({
|
||||
key: p.key,
|
||||
market: info.market,
|
||||
title: p.name,
|
||||
description: p.description,
|
||||
});
|
||||
n++;
|
||||
}
|
||||
toast.success(t("plugin.addedMany", { count: n }));
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
if (n) onAdded();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (k: string) =>
|
||||
setPicked((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(k)) next.delete(k);
|
||||
else next.add(k);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
className="space-y-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (link.trim()) void read();
|
||||
}}
|
||||
>
|
||||
<label className={label}>
|
||||
{t("plugin.link")}
|
||||
<div className="mt-0.5 flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
value={link}
|
||||
autoFocus
|
||||
placeholder="https://git.example.com/org/claude-skills.git"
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary shrink-0 text-sm"
|
||||
disabled={busy || !link.trim()}
|
||||
>
|
||||
{busy && !info ? t("plugin.reading") : t("plugin.read")}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<p className={hint}>{t("plugin.linkHint")}</p>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded border border-status-danger/40 px-2 py-1 text-xs text-status-danger"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{info && (
|
||||
<div className="space-y-2" data-testid="market-plugins">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
|
||||
{info.name}
|
||||
</p>
|
||||
{info.description && <p className={hint}>{info.description}</p>}
|
||||
</div>
|
||||
{info.plugins.length === 0 ? (
|
||||
<p className={hint}>{t("plugin.none")}</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.size === info.plugins.length}
|
||||
onChange={(e) =>
|
||||
setPicked(new Set(e.target.checked ? info.plugins.map((p) => p.key) : []))
|
||||
}
|
||||
/>
|
||||
{t("plugin.all")}
|
||||
</label>
|
||||
<ul className="max-h-64 space-y-1 overflow-auto">
|
||||
{info.plugins.map((p) => (
|
||||
<li key={p.key}>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 hover:bg-surface-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={picked.has(p.key)}
|
||||
onChange={() => toggle(p.key)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-fg-primary">{p.name}</span>
|
||||
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">{p.key}</span>
|
||||
{p.version && (
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">v{p.version}</span>
|
||||
)}
|
||||
{have.has(p.key) && (
|
||||
<span className="ml-1.5 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
|
||||
{t("plugin.have")}
|
||||
</span>
|
||||
)}
|
||||
{p.description && (
|
||||
<span className="block text-[11px] text-fg-muted">{p.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
disabled={busy || picked.size === 0}
|
||||
onClick={() => void addPicked()}
|
||||
>
|
||||
{t("plugin.addPicked", { count: picked.size })}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border-light pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent hover:underline"
|
||||
aria-expanded={manual}
|
||||
onClick={() => setManual((v) => !v)}
|
||||
>
|
||||
{t("plugin.manual")}
|
||||
</button>
|
||||
{manual && (
|
||||
<form
|
||||
className="mt-2 space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.artifacts.addPlugin({
|
||||
key: key.trim(),
|
||||
market: market.trim(),
|
||||
title,
|
||||
description,
|
||||
});
|
||||
toast.success(t("added"));
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="my-plugin@my-marketplace"
|
||||
aria-label={t("form.pluginKey")}
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="owner/repo · https://… · git@host:org/repo.git"
|
||||
aria-label={t("form.market")}
|
||||
value={market}
|
||||
onChange={(e) => setMarket(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
placeholder={t("form.title")}
|
||||
aria-label={t("form.title")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={field}
|
||||
rows={2}
|
||||
placeholder={t("form.description")}
|
||||
aria-label={t("form.description")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={busy || !key.trim() || !market.trim()}
|
||||
>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File upload ───────────────────────────────────────────────────────────
|
||||
|
||||
export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const tooBig = !!file && file.size > 25 * 1024 * 1024;
|
||||
return (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.artifacts.uploadFile(file, { title: title.trim() || undefined });
|
||||
toast.success(t("added"));
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
onAdded();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className={label}>
|
||||
{t("file.fromComputer")}
|
||||
<input
|
||||
type="file"
|
||||
data-testid="file-upload"
|
||||
className="mt-0.5 block w-full text-xs text-fg-secondary"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
{tooBig && <p className="text-xs text-status-danger">{t("file.tooBig")}</p>}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
placeholder={t("form.title")}
|
||||
aria-label={t("form.title")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary shrink-0 text-sm"
|
||||
disabled={busy || !file || tooBig}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,11 @@
|
||||
* the reader is there: anything that makes it taller — a new line, the live
|
||||
* step, an image loading, the pane shown again after being hidden by CSS
|
||||
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
||||
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
|
||||
* 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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -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 { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
|
||||
@@ -76,6 +82,10 @@ const POLL_INTERVAL_MS = 3000;
|
||||
// Rescan the transcripts list periodically so new subagents that spawn
|
||||
// mid-session appear in the dropdown without a page reload.
|
||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
// A hook burst (Pre + Post per tool call, several sessions) refetches the tail
|
||||
// at most this often: the first event still fetches at once, the last is never
|
||||
// lost, and a long transcript is not re-read for every single frame.
|
||||
const EVENT_FETCH_MS = 1500;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
@@ -89,18 +99,47 @@ interface ConversationViewProps {
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
follow?: number;
|
||||
/** The CLI is asking the user something (a permission prompt). */
|
||||
waiting?: boolean;
|
||||
/** Shows the pane's terminal, where that question is answered. */
|
||||
onOpenTerminal?: () => void;
|
||||
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
|
||||
stopped?: number;
|
||||
/** The newest reply's prompt size (input + cache) and model: how full the
|
||||
* context is. */
|
||||
onContext?: (tokens: number, model: string | null) => void;
|
||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConversationView({
|
||||
function ConversationViewPane({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
onOpenFile,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
stopped = 0,
|
||||
onContext,
|
||||
onNotStarted,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -128,6 +167,10 @@ export function ConversationView({
|
||||
// Bumped whenever the session or the selected transcript changes: a tail
|
||||
// fetch started for the previous transcript must not append to the new one.
|
||||
const genRef = useRef(0);
|
||||
// A pane of another workspace or tab stays mounted but hidden: it neither
|
||||
// polls nor follows events, and catches up once when it is shown again.
|
||||
const onShowRef = useRef<() => void>(() => {});
|
||||
const onScreen = useOnScreen(scrollContainerRef, () => onShowRef.current());
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
|
||||
@@ -146,7 +189,7 @@ export function ConversationView({
|
||||
}
|
||||
loadTranscripts();
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") loadTranscripts();
|
||||
if (isShown(onScreen)) loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -178,6 +221,7 @@ export function ConversationView({
|
||||
limit: 50,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setNotStarted(false);
|
||||
setMessages(result.messages);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
@@ -185,7 +229,11 @@ export function ConversationView({
|
||||
firstLineRef.current = result.first_line;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||
// A live pane's new session has no row until its first hook: nothing
|
||||
// to show yet, and the poll below picks it up once it exists.
|
||||
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
|
||||
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||
setNotStarted(notYet);
|
||||
setMessages([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
@@ -225,6 +273,7 @@ export function ConversationView({
|
||||
limit: 50,
|
||||
});
|
||||
if (gen !== genRef.current) return; // another transcript is shown now
|
||||
setNotStarted(false);
|
||||
if (result.messages.length === 0) return;
|
||||
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -260,24 +309,33 @@ export function ConversationView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, selectedTranscript]);
|
||||
|
||||
// WebSocket subscription: refetch on every new_event for this session.
|
||||
// WebSocket subscription: refetch on new_event for this session, throttled.
|
||||
// Hook coverage isn't complete (a user-typed message fires no hook), so we
|
||||
// also poll below to catch what WS misses.
|
||||
const fetchSoon = useMemo(
|
||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab: the visibility handler below catches up once on show.
|
||||
if (document.visibilityState !== "visible") return;
|
||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||
if (!isShown(onScreen)) return;
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
fetchNewMessages();
|
||||
fetchSoon();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchNewMessages();
|
||||
fetchSoon();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchNewMessages]);
|
||||
}, [sessionId, fetchSoon, onScreen]);
|
||||
|
||||
onShowRef.current = () => {
|
||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||
};
|
||||
|
||||
// Resync on WebSocket reconnect: events that landed during a transient
|
||||
// disconnect are gone from the bus, but the JSONL still has them, so a
|
||||
@@ -298,7 +356,7 @@ export function ConversationView({
|
||||
function start() {
|
||||
if (interval !== null) return;
|
||||
interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") fetchNewMessages();
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
function stop() {
|
||||
@@ -324,7 +382,7 @@ export function ConversationView({
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchNewMessages]);
|
||||
}, [fetchNewMessages, onScreen]);
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
@@ -491,7 +549,9 @@ export function ConversationView({
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
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 */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
@@ -513,6 +573,22 @@ export function ConversationView({
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.notStartedBody")}
|
||||
</p>
|
||||
{onOpenTerminal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenTerminal}
|
||||
className="btn-secondary mt-3 text-xs"
|
||||
>
|
||||
{t("conversation.openTerminal")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
@@ -521,7 +597,15 @@ export function ConversationView({
|
||||
</p>
|
||||
</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>
|
||||
@@ -562,3 +646,7 @@ export function ConversationView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Memoized: a chat's prompt box lives in its parent (PaneChat), and every
|
||||
* keystroke there re-rendered the whole conversation. */
|
||||
export const ConversationView = memo(ConversationViewPane);
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
||||
* same links for plain text.
|
||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||
* when the page provides one (the page resolves a relative one against its own
|
||||
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
|
||||
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
||||
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
||||
* of how serious it is.
|
||||
*
|
||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||
@@ -79,7 +84,12 @@ const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
|
||||
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
|
||||
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
|
||||
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
||||
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
||||
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
|
||||
|
||||
/** A GFM table starts at a line holding a pipe that is followed by a divider. */
|
||||
function isTableStart(lines: string[], i: number): boolean {
|
||||
return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!);
|
||||
}
|
||||
|
||||
function splitTableRow(line: string): string[] {
|
||||
// Trim leading/trailing pipes, then split, respecting escaped pipes.
|
||||
@@ -166,7 +176,7 @@ function parseBlocks(src: string): Block[] {
|
||||
}
|
||||
|
||||
// Table: header line followed by an alignment divider
|
||||
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) {
|
||||
if (isTableStart(lines, i)) {
|
||||
const header = splitTableRow(line);
|
||||
const aligns = parseAlignments(lines[i + 1]!);
|
||||
i += 2;
|
||||
@@ -222,7 +232,8 @@ function parseBlocks(src: string): Block[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: collect until a blank line or the start of another block
|
||||
// Paragraph: collect until a blank line or the start of another block —
|
||||
// a table included, which (as in GFM) may follow prose with no blank line.
|
||||
const paraLines: string[] = [line];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
@@ -234,7 +245,8 @@ function parseBlocks(src: string): Block[] {
|
||||
HR_RE.test(nl) ||
|
||||
UL_RE.test(nl) ||
|
||||
OL_RE.test(nl) ||
|
||||
QUOTE_RE.test(nl)
|
||||
QUOTE_RE.test(nl) ||
|
||||
isTableStart(lines, i)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
@@ -253,9 +265,37 @@ const LINK_CLASS =
|
||||
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
|
||||
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
|
||||
// A relative path needs a folder AND an extension starting with a letter
|
||||
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
|
||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||
const REL_PATH_RE =
|
||||
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||
/** A path at the start of `text`, absolute or relative, or null. */
|
||||
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||
// A markdown link target that is a page, not a file.
|
||||
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
||||
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
|
||||
// "2fd0c903" stay text.
|
||||
const NUM_RE =
|
||||
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
|
||||
const numStart = (text: string, i: number) =>
|
||||
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
|
||||
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
|
||||
const LABEL_RE =
|
||||
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
|
||||
const LABEL_CLASS = (word: string) =>
|
||||
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
|
||||
? "text-status-danger"
|
||||
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
|
||||
? "text-status-warning"
|
||||
: "text-accent";
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
@@ -265,6 +305,20 @@ function UrlLink({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
|
||||
function decodeHref(href: string): string {
|
||||
let p = href
|
||||
.replace(/^file:\/\//i, "")
|
||||
.replace(/#L(\d+)(?:-L?\d+)?$/, ":$1")
|
||||
.replace(/#.*$/, "");
|
||||
try {
|
||||
p = decodeURIComponent(p);
|
||||
} catch {
|
||||
/* a stray % stays as written */
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A path as a button that opens it, or as it was when nothing can. */
|
||||
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
|
||||
const open = React.useContext(OpenFileContext);
|
||||
@@ -273,7 +327,7 @@ function PathLink({ path, children }: { path: string; children: React.ReactNode
|
||||
<button
|
||||
type="button"
|
||||
title={path}
|
||||
onClick={() => open(path)}
|
||||
onClick={() => open(stripLine(path))}
|
||||
className={`${LINK_CLASS} text-left`}
|
||||
>
|
||||
{children}
|
||||
@@ -288,7 +342,7 @@ export function linkify(text: string): React.ReactNode[] {
|
||||
for (let i = 0; i < text.length;) {
|
||||
const rest = text.slice(i);
|
||||
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
|
||||
const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
|
||||
const path = !url && atWordStart(text, i) && matchPath(rest);
|
||||
const hit = url || path;
|
||||
if (!hit) {
|
||||
buf += text[i++];
|
||||
@@ -339,13 +393,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(
|
||||
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
@@ -354,7 +402,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||||
if (boldM) {
|
||||
push(
|
||||
<strong className="font-semibold text-fg-primary">
|
||||
<strong className="font-bold text-accent">
|
||||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||||
</strong>
|
||||
);
|
||||
@@ -389,9 +437,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
// Markdown link
|
||||
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
|
||||
if (linkM) {
|
||||
const href = linkM[2]!;
|
||||
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
|
||||
if (
|
||||
/^file:\/\//i.test(href) ||
|
||||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
||||
) {
|
||||
push(
|
||||
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
|
||||
);
|
||||
i += linkM[0].length;
|
||||
continue;
|
||||
}
|
||||
push(
|
||||
<a
|
||||
href={linkM[2]!}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
@@ -412,10 +472,28 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
}
|
||||
|
||||
// An absolute path at a word start
|
||||
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
|
||||
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
|
||||
if (pathM) {
|
||||
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
|
||||
i += pathM[0].length;
|
||||
push(<PathLink path={pathM}>{pathM}</PathLink>);
|
||||
i += pathM.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,27 @@
|
||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||
* when the caller can (`onOpenFile`). A thinking block that carries text is
|
||||
* 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. 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
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -65,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 { useTranslation } from "react-i18next";
|
||||
import i18n from "../../i18n";
|
||||
@@ -84,6 +94,11 @@ import {
|
||||
Cog,
|
||||
FileText,
|
||||
Loader2,
|
||||
Check,
|
||||
Hand,
|
||||
SquareTerminal,
|
||||
MessageCircleQuestion,
|
||||
CornerDownRight,
|
||||
} from "lucide-react";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
|
||||
@@ -128,6 +143,8 @@ const SENDER_STYLES: Record<
|
||||
};
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
||||
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
|
||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
|
||||
@@ -138,6 +155,14 @@ interface MessageListProps {
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** The session is running in a pane: show the step in progress. */
|
||||
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 */
|
||||
@@ -154,9 +179,20 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
|
||||
return map;
|
||||
}
|
||||
|
||||
type ResultMap = ReturnType<typeof buildToolResultMap>;
|
||||
|
||||
/** The map is rebuilt on every tail append, but a block's own result keeps its
|
||||
* identity (old messages are kept, not copied): comparing just those lets a
|
||||
* memoized row skip the re-render when nothing it shows changed. */
|
||||
const sameResults = (blocks: TranscriptContent[], a: ResultMap, b: ResultMap) =>
|
||||
a === b || blocks.every((bl) => !bl.id || a.get(bl.id) === b.get(bl.id));
|
||||
|
||||
const sameBlocks = (a: TranscriptContent[], b: TranscriptContent[]) =>
|
||||
a === b || (a.length === b.length && a.every((bl, i) => bl === b[i]));
|
||||
|
||||
/** The whole prompt a message was answered from: `input_tokens` is only its
|
||||
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
|
||||
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||
return (
|
||||
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
|
||||
);
|
||||
@@ -203,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 }) {
|
||||
const [name, ...args] = display.split(" ");
|
||||
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">
|
||||
<span className="text-status-success/70">›</span>
|
||||
<span className="break-all">{display}</span>
|
||||
<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="font-semibold text-accent">{name}</span>
|
||||
{args.length > 0 && (
|
||||
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -420,7 +476,9 @@ type Item =
|
||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||
| { 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"]);
|
||||
|
||||
@@ -429,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
|
||||
// human speaks again (or the list ends).
|
||||
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) => {
|
||||
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: 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) => {
|
||||
const key = keys[idx]!;
|
||||
@@ -441,6 +510,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
if (sender === "user") {
|
||||
endTurn(key);
|
||||
start = msg.timestamp ?? null;
|
||||
return items.push({ kind: "user", key, msg });
|
||||
}
|
||||
if (sender !== "assistant") {
|
||||
@@ -463,10 +533,15 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
}
|
||||
return items.push({ kind: "row", key, msg });
|
||||
}
|
||||
reply = msg;
|
||||
if (msg.usage) output.add(msg.usage);
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
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);
|
||||
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
||||
@@ -480,7 +555,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
|
||||
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
|
||||
* the lines added and removed. A call still running or failed is left out. */
|
||||
function EditedFiles({
|
||||
function EditedFilesCard({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
onOpenFile,
|
||||
@@ -521,7 +596,11 @@ function EditedFiles({
|
||||
);
|
||||
return (
|
||||
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => dragAttachments(e, [...files.keys()].map(projectAttachment))}
|
||||
className="flex cursor-grab items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
|
||||
{counts(added, removed)}
|
||||
@@ -533,6 +612,8 @@ function EditedFiles({
|
||||
title={p}
|
||||
disabled={!onOpenFile}
|
||||
onClick={() => onOpenFile?.(p)}
|
||||
draggable
|
||||
onDragStart={(e) => dragAttachments(e, [projectAttachment(p)])}
|
||||
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
||||
@@ -544,15 +625,39 @@ function EditedFiles({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const EditedFiles = memo(
|
||||
EditedFilesCard,
|
||||
(p, n) =>
|
||||
p.onOpenFile === n.onOpenFile &&
|
||||
sameBlocks(p.blocks, n.blocks) &&
|
||||
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
|
||||
);
|
||||
|
||||
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
|
||||
function MessageListView({
|
||||
messages,
|
||||
loading,
|
||||
onOpenFile,
|
||||
live = false,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
stopped = 0,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const pending = useMemo(
|
||||
() => (live ? workingOn(messages, toolResultMap) : null),
|
||||
[live, messages, toolResultMap]
|
||||
);
|
||||
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]);
|
||||
|
||||
if (loading) {
|
||||
@@ -590,6 +695,14 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
return (
|
||||
<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":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
@@ -601,9 +714,19 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
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>
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
@@ -613,88 +736,349 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
// written (a crash, a kill) — stop saying it works.
|
||||
const IDLE_AFTER_MS = 15 * 60_000;
|
||||
|
||||
/** What the model is doing while its turn is not over: the call still
|
||||
* waiting for its result (`block`), else thinking (`block: null`); `since`
|
||||
* is that line's time. Null once the turn ended. */
|
||||
/** What the model is doing while its turn is not over: the calls of the turn
|
||||
* still waiting for their results (`blocks`, oldest first — parallel calls
|
||||
* 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(
|
||||
messages: TranscriptMessage[],
|
||||
results: Map<string, TranscriptContent>,
|
||||
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--) {
|
||||
const msg = messages[i]!;
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
// A rename marker or a harness line says nothing about the turn.
|
||||
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
|
||||
const since = msg.timestamp ?? null;
|
||||
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
|
||||
const at = msg.timestamp ?? null;
|
||||
const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS;
|
||||
const newest = alive === undefined;
|
||||
if (newest) last = at;
|
||||
if (msg.type === "assistant") {
|
||||
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
|
||||
if (call?.id && !results.has(call.id)) return { block: call, since };
|
||||
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
|
||||
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
|
||||
// "end_turn": over, whatever the transcript failed to pair.
|
||||
if (newest && msg.stop_reason === "end_turn") return null;
|
||||
for (const b of [...msg.content].reverse()) {
|
||||
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.
|
||||
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
|
||||
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. */
|
||||
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
|
||||
/** Output tokens counted once per API call: Claude Code writes one line per
|
||||
* 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 [now, setNow] = useState(() => Date.now());
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
// A hidden tab or a hidden pane (another workspace, kept mounted) shows
|
||||
// no clock: skip the re-render until it is on screen again.
|
||||
const timer = window.setInterval(() => {
|
||||
const el = boxRef.current;
|
||||
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
|
||||
return;
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const input = block?.input as Record<string, unknown> | undefined;
|
||||
const title = !block
|
||||
? t("conversation.working")
|
||||
: typeof input?.description === "string" && input.description
|
||||
? input.description
|
||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||
const counted = useCountUp(tokens);
|
||||
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
|
||||
const first = blocks[0];
|
||||
const step = first ? age(since) : 0;
|
||||
const quiet = !waiting && age(last) > QUIET_MS;
|
||||
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 (
|
||||
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
{since && (
|
||||
<span className="flex-shrink-0 font-mono text-xs">
|
||||
{formatMs(now - parseDate(since).getTime())}
|
||||
<div ref={boxRef} data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{waiting ? (
|
||||
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
|
||||
) : (
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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";
|
||||
|
||||
// What the prompt box sends for an attached image: its saved path under the
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
|
||||
// What the prompt box sends for an attached file: its saved path under the
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
|
||||
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||
const DROP_IMAGE =
|
||||
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||
const DROP_PATH =
|
||||
/(?<=^|\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`).
|
||||
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
|
||||
|
||||
/** A user turn's text without its dropped-image paths (and Claude Code's
|
||||
* `[Image #N]` placeholders for them), and those images' names. */
|
||||
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||
/** A user turn's text without its dropped-file paths (and Claude Code's
|
||||
* `[Image #N]` placeholders for them), the images' names and the other
|
||||
* files' full paths. */
|
||||
export function splitDropImages(text: string): {
|
||||
text: string;
|
||||
images: string[];
|
||||
files: string[];
|
||||
} {
|
||||
const images: string[] = [];
|
||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||
images.push(name);
|
||||
const files: string[] = [];
|
||||
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 {
|
||||
text: images.length
|
||||
? rest
|
||||
.replace(/\[Image #\d+\]/g, "")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim()
|
||||
: text,
|
||||
text:
|
||||
images.length || files.length
|
||||
? rest
|
||||
.replace(/\[Image #\d+\]/g, "")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim()
|
||||
: text,
|
||||
images,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -705,6 +1089,30 @@ function imageSourcePaths(msg: TranscriptMessage): string[] | 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 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gone, setGone] = useState(false);
|
||||
@@ -752,6 +1160,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
[msg.content]
|
||||
);
|
||||
const images = blocks.flatMap((b) => b?.images ?? []);
|
||||
const files = blocks.flatMap((b) => b?.files ?? []);
|
||||
const hasText = blocks.some((b) => b?.text);
|
||||
return (
|
||||
<div
|
||||
@@ -759,7 +1168,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||
className="group flex flex-col items-end gap-1"
|
||||
>
|
||||
{images.length > 0 && (
|
||||
{images.length + files.length > 0 && (
|
||||
<div
|
||||
data-testid="chat-user-images"
|
||||
className="flex max-w-[85%] flex-wrap justify-end gap-2"
|
||||
@@ -767,6 +1176,9 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
{images.map((name) => (
|
||||
<DropImage key={name} name={name} />
|
||||
))}
|
||||
{files.map((path) => (
|
||||
<DropFile key={path} path={path} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasText && (
|
||||
@@ -784,7 +1196,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{linkify(stripAnsi(text).trim())}
|
||||
{typedText(stripAnsi(text).trim())}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(text, b)
|
||||
@@ -983,7 +1395,7 @@ function WorkStep({
|
||||
|
||||
/** What the assistant did between two replies as one line, opening onto
|
||||
* one framed list of its steps. */
|
||||
const WorkGroup = memo(function WorkGroup({
|
||||
function WorkGroupLine({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
}: {
|
||||
@@ -1005,11 +1417,16 @@ const WorkGroup = memo(function WorkGroup({
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
const WorkGroup = memo(
|
||||
WorkGroupLine,
|
||||
(p, n) =>
|
||||
sameBlocks(p.blocks, n.blocks) && sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
|
||||
);
|
||||
|
||||
/** One user/assistant row. Memoized: a live tail append re-renders only the
|
||||
* new rows; its thinking-block expansion is its own state, kept by key. */
|
||||
const MessageRow = memo(function MessageRow({
|
||||
function MessageRowView({
|
||||
msg,
|
||||
toolResultMap,
|
||||
}: {
|
||||
@@ -1128,4 +1545,11 @@ const MessageRow = memo(function MessageRow({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
const MessageRow = memo(
|
||||
MessageRowView,
|
||||
(p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap)
|
||||
);
|
||||
|
||||
/** Memoized: its owner re-renders on scroll state and its parent's typing. */
|
||||
export const MessageList = memo(MessageListView);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @file ConversationView.hidden.test.tsx
|
||||
* @description A chat kept mounted in a hidden pane (another workspace or
|
||||
* tab, `display:none`) does not follow the live stream: a new_event for its
|
||||
* session fetches nothing while its box is off screen, and showing it again
|
||||
* catches the tail up once. On screen, a burst of events is throttled to one
|
||||
* fetch at once plus one after the burst.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
import { api } from "../../../lib/api";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let intersect: ((visible: boolean) => void) | null = null;
|
||||
const original = globalThis.IntersectionObserver;
|
||||
beforeEach(() => {
|
||||
intersect = null;
|
||||
// @ts-expect-error test stub
|
||||
globalThis.IntersectionObserver = class {
|
||||
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
|
||||
intersect = (visible) => cb([{ isIntersecting: visible }]);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.IntersectionObserver = original;
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const event = () =>
|
||||
act(() => eventBus.publish({ type: "new_event", data: { session_id: "s1" } } as never));
|
||||
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||
|
||||
describe("ConversationView in a hidden pane", () => {
|
||||
it("fetches nothing while hidden and catches up once when shown", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const before = calls();
|
||||
|
||||
act(() => intersect?.(false));
|
||||
event();
|
||||
event();
|
||||
expect(calls()).toBe(before);
|
||||
|
||||
act(() => intersect?.(true));
|
||||
await act(async () => {});
|
||||
expect(calls()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("throttles a burst of events on screen", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
vi.useFakeTimers();
|
||||
const before = calls();
|
||||
event();
|
||||
event();
|
||||
event();
|
||||
// The first fetches at once; the rest wait for the throttle window.
|
||||
expect(calls()).toBe(before + 1);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1600);
|
||||
});
|
||||
expect(calls()).toBe(before + 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file ConversationView.notYet.test.tsx
|
||||
* @description A live pane's new session has no row until its first hook:
|
||||
* its 404 shows no "Session not found" (a replay still shows it) but says the
|
||||
* session has not started — the CLI may be asking in the terminal, e.g. to
|
||||
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
|
||||
const transcript = vi.hoisted(() =>
|
||||
vi.fn(async () => {
|
||||
throw Object.assign(new Error("Session not found"), { code: "NOT_FOUND", status: 404 });
|
||||
})
|
||||
);
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript } },
|
||||
};
|
||||
});
|
||||
|
||||
describe("ConversationView before a session's first hook", () => {
|
||||
it("shows no error for a live pane", async () => {
|
||||
render(<ConversationView sessionId="new" fill live />);
|
||||
await waitFor(() => expect(transcript).toHaveBeenCalled());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(screen.queryByText("Session not found")).toBeNull();
|
||||
});
|
||||
|
||||
it("still reports it when not live", async () => {
|
||||
render(<ConversationView sessionId="gone" fill />);
|
||||
expect(await screen.findByText("Session not found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
|
||||
const onOpenTerminal = vi.fn();
|
||||
const onNotStarted = vi.fn();
|
||||
render(
|
||||
<ConversationView
|
||||
sessionId="new"
|
||||
fill
|
||||
live
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onNotStarted={onNotStarted}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
|
||||
expect(screen.queryByText("No conversation records found.")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Open terminal"));
|
||||
expect(onOpenTerminal).toHaveBeenCalled();
|
||||
expect(onNotStarted).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,52 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
|
||||
import { resolveIn } from "../../../lib/resolvePath";
|
||||
|
||||
function withOpen(text: string) {
|
||||
const open = vi.fn();
|
||||
render(
|
||||
<OpenFileContext.Provider value={open}>
|
||||
<MarkdownContent text={text} />
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
return open;
|
||||
}
|
||||
|
||||
describe("file links", () => {
|
||||
it("opens a relative path with its line suffix stripped", () => {
|
||||
const open = withOpen("See server/lib/x.js:42 for it.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
|
||||
expect(open).toHaveBeenCalledWith("server/lib/x.js");
|
||||
});
|
||||
|
||||
it("opens an absolute path with a line suffix and one in inline code", () => {
|
||||
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
|
||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||
});
|
||||
|
||||
it("opens a markdown link to a file instead of navigating", () => {
|
||||
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
|
||||
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
|
||||
});
|
||||
|
||||
it("leaves prose with slashes as text", () => {
|
||||
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resolves a relative path against the pane's folder", () => {
|
||||
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
|
||||
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MarkdownContent />", () => {
|
||||
it("renders fenced code blocks with the language label", () => {
|
||||
@@ -66,10 +110,61 @@ describe("<MarkdownContent />", () => {
|
||||
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent
|
||||
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
|
||||
/>
|
||||
);
|
||||
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
|
||||
});
|
||||
|
||||
it("highlights an ISO date-time as one number", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent
|
||||
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
|
||||
/>
|
||||
);
|
||||
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||
expect(nums).toEqual([
|
||||
"2026-10-12T08:00",
|
||||
"2026-10-12T08:00:00Z",
|
||||
"2026-10-12T08:00:00.123+07:00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not highlight numbers inside inline code", () => {
|
||||
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
|
||||
expect(container.querySelector("span.tabular-nums")).toBeNull();
|
||||
});
|
||||
|
||||
it("colours a flag label by how serious it is", () => {
|
||||
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
|
||||
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
|
||||
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
|
||||
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
|
||||
expect(screen.queryByText("Note")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles empty input safely", () => {
|
||||
const { container } = render(<MarkdownContent text="" />);
|
||||
// Wrapper exists but no block elements
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders a table that follows prose with no blank line (issue #83)", () => {
|
||||
const text = 'Cách kiểm tra:\n| Token xoá | Số chỗ |\n|---|---|\n| `width="N"` | 29 |';
|
||||
const { container } = render(<MarkdownContent text={text} />);
|
||||
expect(container.querySelector("p")?.textContent).toBe("Cách kiểm tra:");
|
||||
expect(container.querySelectorAll("th").length).toBe(2);
|
||||
expect(container.querySelectorAll("tbody tr").length).toBe(1);
|
||||
expect(container.textContent).not.toContain("|---|");
|
||||
});
|
||||
|
||||
it("accepts a GFM divider with single dashes", () => {
|
||||
const { container } = render(<MarkdownContent text={"| a | b |\n|-|:-:|\n| 1 | 2 |"} />);
|
||||
expect(container.querySelectorAll("th").length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 @@
|
||||
* @file MessageList.edits.test.tsx
|
||||
* @description Each assistant turn that changed files ends with Claude
|
||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||
* removed summed over the turn, a failed call left out, and a row click handed
|
||||
* to the caller.
|
||||
* removed summed over the turn, a failed call left out, a row click handed
|
||||
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||
* attachments for a prompt box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
@@ -67,4 +68,36 @@ describe("the Edited N files card", () => {
|
||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||
});
|
||||
|
||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
result("2", { added: 2, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("edited-files");
|
||||
const drag = (el: Element) => {
|
||||
const data: Record<string, string> = {};
|
||||
fireEvent.dragStart(el, {
|
||||
dataTransfer: { setData: (k: string, v: string) => (data[k] = v), effectAllowed: "" },
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||
{ path: "@/w/b.md", name: "b.md" },
|
||||
]);
|
||||
expect(one["text/plain"]).toBe("/w/b.md");
|
||||
const all = drag(within(card).getByText("Edited 2 files"));
|
||||
expect(
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @file MessageList.images.test.tsx
|
||||
* @description An image the prompt box attached reaches the transcript as its
|
||||
* saved drop path; the user's turn shows the image (served by
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
||||
* other path as typed. Claude Code turns a pasted drop path into an image
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page; any other
|
||||
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
|
||||
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
||||
* URLs and absolute paths in a turn are links.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -19,7 +19,7 @@ describe("dropped images in a user turn", () => {
|
||||
it("splitDropImages takes drop-dir image paths out of the text", () => {
|
||||
expect(
|
||||
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
|
||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
|
||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
|
||||
// Windows' temp dir, no prefix name.
|
||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||
"0123456789abcdef.webp",
|
||||
@@ -28,16 +28,36 @@ describe("dropped images in a user turn", () => {
|
||||
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
||||
text: "fix this",
|
||||
images: [SHOT],
|
||||
files: [],
|
||||
});
|
||||
// Not a drop, or not an image: left as typed.
|
||||
for (const text of [
|
||||
"/home/me/shot.png",
|
||||
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
|
||||
]) {
|
||||
expect(splitDropImages(text)).toEqual({ text, images: [] });
|
||||
// Any other dropped file comes out by its full path.
|
||||
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
|
||||
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
|
||||
// Not a drop: left as typed.
|
||||
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
|
||||
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a dropped file as a chip that opens its preview", () => {
|
||||
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
|
||||
const opened: string[] = [];
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T08:00:00.000Z",
|
||||
content: [{ type: "text", text: `summarise ${pdf}` }],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).not.toHaveTextContent("0123456789abcdef");
|
||||
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
|
||||
expect(opened).toEqual([pdf]);
|
||||
});
|
||||
|
||||
it("shows the image instead of its path, full size on click", () => {
|
||||
const messages = [
|
||||
{
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line.
|
||||
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||
* shows as a chip.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, workLabel, workingOn } from "../MessageList";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -22,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
}
|
||||
|
||||
describe("MessageList — sender attribution", () => {
|
||||
it("shows a typed /command and @path as chips in the user's bubble", () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
|
||||
]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("/review @src/a.ts now");
|
||||
expect(within(bubble).getByText("/review").className).toContain("text-accent");
|
||||
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
|
||||
});
|
||||
|
||||
it("labels each row by its sender, not blanket 'User'", () => {
|
||||
const messages: TranscriptMessage[] = [
|
||||
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
||||
@@ -126,11 +142,17 @@ describe("MessageList — sender attribution", () => {
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
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({
|
||||
type: "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: [
|
||||
{
|
||||
type: "tool_use",
|
||||
@@ -144,7 +166,8 @@ describe("MessageList — sender attribution", () => {
|
||||
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
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.
|
||||
rerender(
|
||||
<MessageList
|
||||
@@ -159,6 +182,121 @@ describe("MessageList — sender attribution", () => {
|
||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||
});
|
||||
|
||||
// Issue #74: Esc before the model wrote anything takes the prompt back into
|
||||
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
|
||||
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
|
||||
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
|
||||
const prompt = msg({
|
||||
sender: "user",
|
||||
timestamp: new Date(Date.now() - 14_000).toISOString(),
|
||||
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
|
||||
});
|
||||
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
|
||||
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
|
||||
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
|
||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||
// The next prompt is a new turn: its Thinking… shows.
|
||||
const next = msg({
|
||||
sender: "user",
|
||||
timestamp: new Date().toISOString(),
|
||||
content: [{ type: "text", text: "again" }],
|
||||
});
|
||||
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
|
||||
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
|
||||
});
|
||||
|
||||
it("counts a turn's output once per API call, since its prompt", () => {
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const line = (cache: number, out: number) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "text", text: "x" }],
|
||||
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
|
||||
});
|
||||
const messages = [
|
||||
msg({ sender: "user" }),
|
||||
line(10, 999), // an earlier turn
|
||||
msg({ sender: "user", timestamp: at }),
|
||||
line(100, 40),
|
||||
line(100, 40), // the same call's second block
|
||||
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
|
||||
line(200, 25),
|
||||
];
|
||||
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
|
||||
});
|
||||
|
||||
it("keeps every parallel call of the turn running until its own result", () => {
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const call = (id: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
|
||||
});
|
||||
const done = (id: string) =>
|
||||
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
|
||||
// Claude Code writes each parallel call on its own line; one result is in.
|
||||
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
|
||||
const results = new Map([["b", m[4]!.content[0]!]]);
|
||||
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
|
||||
// A call left unpaired in an EARLIER turn is not this one's.
|
||||
const next = [
|
||||
...m,
|
||||
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
|
||||
];
|
||||
expect(workingOn(next, results, now)?.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("says when the CLI waits on the user, and offers the terminal", () => {
|
||||
let opened = 0;
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
live
|
||||
waiting
|
||||
onOpenTerminal={() => opened++}
|
||||
messages={[
|
||||
msg({ sender: "user", timestamp: new Date().toISOString() }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
content: [
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
|
||||
fireEvent.click(screen.getByTestId("chat-open-terminal"));
|
||||
expect(opened).toBe(1);
|
||||
});
|
||||
|
||||
it("closes an ended turn with its time and output", () => {
|
||||
const reply = (out: number, stop_reason: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: "2026-06-26T08:16:08.000Z",
|
||||
stop_reason,
|
||||
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
});
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
|
||||
});
|
||||
|
||||
it("knows what a live turn is doing until it ends", () => {
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
@@ -189,10 +327,11 @@ describe("MessageList — sender attribution", () => {
|
||||
);
|
||||
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
|
||||
|
||||
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
|
||||
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
|
||||
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
|
||||
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
|
||||
const thinking = { blocks: [], since: null, last: at };
|
||||
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
|
||||
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
|
||||
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
|
||||
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
|
||||
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
|
||||
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
|
||||
const stopped = msg({
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* @file AddLaneModal.tsx
|
||||
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||||
* @description The "+ Add lane" flow, in one of three modes chosen with a
|
||||
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
|
||||
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
|
||||
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||
* main repo you want stage detection on); "Worktree" provisions a
|
||||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||
* manually-typed branch name. The user also picks which CLI drives the lane
|
||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git). There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
|
||||
@@ -21,7 +25,7 @@
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
@@ -29,7 +33,6 @@ import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
@@ -48,6 +51,17 @@ async function waitForProvisioned(
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
|
||||
export function cloneDirName(url: string): string {
|
||||
return (
|
||||
url
|
||||
.trim()
|
||||
.replace(/[/:]+$/, "")
|
||||
.split(/[/:]/)
|
||||
.pop() || ""
|
||||
).replace(/\.git$/i, "");
|
||||
}
|
||||
|
||||
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||||
function Seg({
|
||||
active,
|
||||
@@ -97,7 +111,18 @@ export function AddLaneModal({
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("repo");
|
||||
const [mode, setMode] = useState<"repo" | "worktree" | "clone">("repo");
|
||||
// Clone mode: the URL, the folder name (blank = the URL's), and — once the
|
||||
// server asked for one — a login for the URL's https host.
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
token: string;
|
||||
remember: boolean;
|
||||
} | null>(null);
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
@@ -110,27 +135,18 @@ export function AddLaneModal({
|
||||
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
|
||||
const [base, setBase] = useState("");
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
// No picker: a workspace's tool is only its DEFAULT — each new session
|
||||
// picks its own AI on the workspace home. This is the default AI tool
|
||||
// (Settings → AI Tools), the account's own pick first while it is
|
||||
// installed and on.
|
||||
const aiTools = useAiTools();
|
||||
// The account's own pick (first-run setup), while it is installed and on.
|
||||
const ownTool = readDefaultTool();
|
||||
const defaultTool =
|
||||
const tool =
|
||||
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
|
||||
? ownTool
|
||||
: null) ??
|
||||
aiTools.defaultId ??
|
||||
"claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
// Installed + enabled, the default first.
|
||||
const toolChoices = aiTools.tools
|
||||
.filter((x) => x.installed && x.enabled)
|
||||
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
|
||||
const [toolListOpen, setToolListOpen] = useState(false);
|
||||
const pickTool = (id: string) => {
|
||||
setPickedTool(id === defaultTool ? null : id);
|
||||
setToolListOpen(false);
|
||||
};
|
||||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -141,6 +157,10 @@ export function AddLaneModal({
|
||||
|
||||
const reset = () => {
|
||||
setMode("repo");
|
||||
setCloneUrl("");
|
||||
setCloneName("");
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setSourceRepo("");
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
@@ -148,7 +168,6 @@ export function AddLaneModal({
|
||||
setWorktrees([]);
|
||||
setBranchMode("new");
|
||||
setBase("");
|
||||
setPickedTool(null);
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
@@ -213,6 +232,52 @@ export function AddLaneModal({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
if (mode === "clone") {
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || url;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
token: "",
|
||||
remember: l?.remember ?? true,
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "repo") {
|
||||
try {
|
||||
const result = await api.lanes.ensure({
|
||||
@@ -328,7 +393,6 @@ export function AddLaneModal({
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const browseRef = useRef<HTMLButtonElement>(null);
|
||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
@@ -338,6 +402,8 @@ export function AddLaneModal({
|
||||
const disabled =
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "clone" &&
|
||||
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
@@ -394,81 +460,58 @@ export function AddLaneModal({
|
||||
title={t("mode.worktreeHint")}
|
||||
onClick={() => setMode("worktree")}
|
||||
/>
|
||||
<Seg
|
||||
active={mode === "clone"}
|
||||
label={t("mode.clone")}
|
||||
title={t("mode.cloneHint")}
|
||||
onClick={() => setMode("clone")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
||||
{noTools ? (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={toolListOpen}
|
||||
disabled={toolChoices.length < 2}
|
||||
onClick={() => setToolListOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
|
||||
>
|
||||
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
|
||||
{tool === defaultTool && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("addLaneToolDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{toolChoices.length > 1 && (
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{toolListOpen && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("addLaneToolHint")}
|
||||
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||
>
|
||||
{toolChoices.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
aria-pressed={x.id === tool}
|
||||
onClick={() => pickTool(x.id)}
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
x.id === tool
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mode === "clone" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-url">
|
||||
{t("clone.urlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-url"
|
||||
value={cloneUrl}
|
||||
onChange={(e) => {
|
||||
setCloneUrl(e.target.value);
|
||||
// A login belongs to the host it was asked for.
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo.git"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noTools && (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
{mode === "repo"
|
||||
? t("addLaneRepoLabelAdopt")
|
||||
: mode === "clone"
|
||||
? t("clone.parentLabel")
|
||||
: t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||
{/* A click into the path opens the picker, as Browse does (the
|
||||
OS's own where it can be); typing still works from the keyboard. */}
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{/* A click into the path only focuses it — the picker opens from
|
||||
Browse alone (#77). */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
@@ -476,18 +519,70 @@ export function AddLaneModal({
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<PathPickButton
|
||||
start={sourceRepo}
|
||||
onPick={setSourceRepo}
|
||||
showLabel
|
||||
buttonRef={browseRef}
|
||||
/>
|
||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
{mode === "clone"
|
||||
? t("clone.parentHint", {
|
||||
path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${
|
||||
cloneName.trim() || cloneDirName(cloneUrl) || "…"
|
||||
}`,
|
||||
})
|
||||
: mode === "repo"
|
||||
? t("addLaneRepoHintAdopt")
|
||||
: t("addLaneRepoHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === "clone" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-clone-name">
|
||||
{t("clone.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-clone-name"
|
||||
value={cloneName}
|
||||
onChange={(e) => setCloneName(e.target.value)}
|
||||
placeholder={cloneDirName(cloneUrl)}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "clone" && login && (
|
||||
<div
|
||||
data-testid="add-lane-clone-login"
|
||||
className="grid gap-2 rounded-md border border-border-light bg-surface-0 p-2 sm:grid-cols-2"
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="off"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="new-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What this repo already has checked out. Without it the form asked
|
||||
for a branch in the dark: the one fact that decides whether a
|
||||
branch can be used at all is which worktree is holding it. */}
|
||||
@@ -701,6 +796,14 @@ export function AddLaneModal({
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
{sshRefused && (
|
||||
<>
|
||||
{" "}
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -721,7 +824,11 @@ export function AddLaneModal({
|
||||
data-tour="add-lane-create"
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{done ? t("addLaneSetupClose") : t("add")}
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
|
||||
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
|
||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||
* added as an extra folder, one after another. A host that wants a login
|
||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||
* URLs with it, like Add workspace → Clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
|
||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
export function CloneSourcesForm({
|
||||
laneId,
|
||||
parent,
|
||||
onAdded,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Where the clones land: the workspace folder's parent, shown only. */
|
||||
parent: string;
|
||||
onAdded: (view: LaneSourcesView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [text, setText] = useState("");
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
|
||||
const urls = (s: string) => [
|
||||
...new Set(
|
||||
s
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
setBusy(true);
|
||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||
const show = () => setRows([...next]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
for (const row of next) {
|
||||
row.state = "busy";
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
row.state = "failed";
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
asked ??= {
|
||||
host,
|
||||
username: login?.username ?? "",
|
||||
token: "",
|
||||
remember: login?.remember ?? true,
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
// What is left to do stays in the box; what was added leaves it.
|
||||
setText(
|
||||
next
|
||||
.filter((r) => r.state !== "done")
|
||||
.map((r) => r.url)
|
||||
.join("\n")
|
||||
);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||
<textarea
|
||||
data-testid="workspace-clone-input"
|
||||
aria-label={t("home.cloneFolders")}
|
||||
placeholder={t("home.clonePlaceholder")}
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
{login && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
{failed.length > 0 && !busy && (
|
||||
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
|
||||
{t("home.cloneRetry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-clone-run"
|
||||
disabled={busy || !urls(text).length}
|
||||
onClick={() => void cloneAll(urls(text))}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("home.cloneRun")}
|
||||
</button>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<ul className="space-y-1" data-testid="workspace-clone-rows">
|
||||
{rows.map((r) => (
|
||||
<li key={r.url} className="text-[11px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{r.state === "busy" ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
|
||||
) : r.state === "done" ? (
|
||||
<Check
|
||||
className="h-3 w-3 shrink-0 text-status-success"
|
||||
aria-label={t("home.cloneDone")}
|
||||
/>
|
||||
) : r.state === "failed" ? (
|
||||
<X className="h-3 w-3 shrink-0 text-status-danger" />
|
||||
) : (
|
||||
<span className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||
* a function that moves the cursor to the next / previous changed block.
|
||||
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||
* Chat chip 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.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
showTooltip,
|
||||
type Tooltip,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
@@ -284,41 +286,34 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
class ChatChip extends WidgetType {
|
||||
constructor(
|
||||
readonly start: number,
|
||||
readonly end: number,
|
||||
readonly build: (start: number, end: number) => HTMLElement
|
||||
) {
|
||||
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. */
|
||||
/** Add to Chat floating just above the selection's last line, at the column
|
||||
* where the selection ends — so a long line never pushes it out of view.
|
||||
* `build` makes its DOM from the selection's first and last 1-based line. A
|
||||
* selection ending at a line's start (a whole-line drag) does not take that
|
||||
* line, and the chip then sits at the start of the last line it does take. */
|
||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||
return StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return deco;
|
||||
return StateField.define<Tooltip | null>({
|
||||
create: () => null,
|
||||
update(tip, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return tip;
|
||||
const { from, to, empty } = tr.state.selection.main;
|
||||
if (empty) return Decoration.none;
|
||||
if (empty) return null;
|
||||
const doc = tr.state.doc;
|
||||
const first = doc.lineAt(from);
|
||||
const last = doc.lineAt(to);
|
||||
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
|
||||
const widget = new ChatChip(first.number, end, build);
|
||||
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
|
||||
const wholeLine = to === last.from && last.number > first.number;
|
||||
const end = wholeLine ? last.number - 1 : last.number;
|
||||
return {
|
||||
pos: wholeLine ? doc.line(end).from : to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = build(first.number, end);
|
||||
dom.classList.add("cm-chat-chip");
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
},
|
||||
provide: (f) => 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-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
||||
// Doubled classes: drawSelection's base theme scopes its light/dark colours
|
||||
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
|
||||
// plain `.cm-selectionBackground` never outranks.
|
||||
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
background: "rgb(var(--accent) / 0.3)",
|
||||
},
|
||||
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
|
||||
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
|
||||
border: "1px solid rgb(var(--accent) / 0.6)",
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
},
|
||||
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
|
||||
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-changedText`]: { background: "none" },
|
||||
@@ -544,7 +549,7 @@ export default function DiffEditor({
|
||||
b.dataset.testid = "lane-editor-add-to-chat";
|
||||
b.textContent = cb.current.t("changes.addToChat");
|
||||
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.
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
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.
|
||||
* Browsers refuse to expose an absolute filesystem path from a native folder
|
||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
|
||||
* up-one-level button and a click-to-descend list, since this tool is
|
||||
* local-first and the server already trusts arbitrary local paths.
|
||||
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
||||
* button, laid out like the OS's own picker. Browsers refuse to expose an
|
||||
* absolute filesystem path from a native picker, so path selection here is
|
||||
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
|
||||
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
|
||||
* (Home, the user's workspaces, recent picks), a filter, Name/Modified columns,
|
||||
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
|
||||
* A click selects, a double-click or Enter opens a folder (picks a file),
|
||||
* Backspace goes up and typing jumps to a name.
|
||||
* An admin with a Linux account of their own (S3; the listing carries
|
||||
* `hostHome`) opens in THEIR home; the "Server folder" tick switches the
|
||||
* browser to the host account's home, where a workspace runs as the shared
|
||||
* host account. It is never remembered: every open starts at the admin's own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Clock,
|
||||
Eye,
|
||||
File,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Pencil,
|
||||
Server,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useWorkLanes } from "../../hooks/useLanes";
|
||||
|
||||
export function FolderBrowseModal({
|
||||
type Listing = Awaited<ReturnType<typeof api.lanes.browse>>;
|
||||
type Entry = Listing["entries"][number];
|
||||
|
||||
const RECENT_KEY = "agenthub.browse.recent";
|
||||
const HIDDEN_KEY = "agenthub.browse.hidden";
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
function readStored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// A private window or blocked storage: the preference just isn't kept.
|
||||
}
|
||||
}
|
||||
|
||||
/** `[label, path]` for each level of `p`, root first. */
|
||||
function crumbs(p: string): [string, string][] {
|
||||
const win = /^[A-Za-z]:\\/.test(p);
|
||||
const sep = win ? "\\" : "/";
|
||||
const parts = p.split(sep).filter(Boolean);
|
||||
const out: [string, string][] = win ? [] : [["/", "/"]];
|
||||
let acc = "";
|
||||
for (const part of parts) {
|
||||
acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`;
|
||||
out.push([part, acc]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p;
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
/** `file` lists files too, and picking one is the only way to select. */
|
||||
kind?: "dir" | "file";
|
||||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||
initialPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mounted only while open, so a closed picker (one behind every Browse
|
||||
* button) asks the server for nothing, and each opening starts fresh.
|
||||
*/
|
||||
export function FolderBrowseModal(props: Props) {
|
||||
return props.open ? <BrowseDialog {...props} /> : null;
|
||||
}
|
||||
|
||||
function BrowseDialog({
|
||||
open,
|
||||
kind = "dir",
|
||||
initialPath,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** `file` lists files too, and picking one is the only way to select. */
|
||||
kind?: "dir" | "file";
|
||||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||
initialPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
||||
const { lanes } = useWorkLanes();
|
||||
const [listing, setListing] = useState<Listing | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [pathText, setPathText] = useState("");
|
||||
const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({
|
||||
key: "name",
|
||||
dir: 1,
|
||||
});
|
||||
const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false));
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [recent, setRecent] = useState<string[]>(() => readStored(RECENT_KEY, []));
|
||||
// The highest folder this user may open (a member's own folder answers no
|
||||
// parent) — breadcrumbs above it are not links.
|
||||
const [floor, setFloor] = useState<string | null>(null);
|
||||
const [home, setHome] = useState<string | null>(null);
|
||||
// An admin's "Server folder" tick (S3): browse the host account's home.
|
||||
const [hostHome, setHostHome] = useState<string | null>(null);
|
||||
const [onHost, setOnHost] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const crumbRef = useRef<HTMLDivElement>(null);
|
||||
const typeAhead = useRef({ text: "", at: 0 });
|
||||
|
||||
const load = async (path?: string) => {
|
||||
const load = async (
|
||||
target?: string,
|
||||
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
setListing(await api.lanes.browse(path));
|
||||
const next = await api.lanes.browse(target, kind === "file", hidden, host);
|
||||
setListing(next);
|
||||
setSelected(select);
|
||||
setFilter("");
|
||||
setEditing(false);
|
||||
if (next.home) setHome(next.home);
|
||||
setHostHome(next.hostHome ?? null);
|
||||
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||||
if (push) {
|
||||
setHistory((h) => {
|
||||
if (h.stack[h.at] === next.path) return h;
|
||||
const stack = [...h.stack.slice(0, h.at + 1), next.path];
|
||||
return { stack, at: stack.length - 1 };
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load(initialPath);
|
||||
if (!open) return;
|
||||
setHistory({ stack: [], at: -1 });
|
||||
setCreating(false);
|
||||
setOnHost(false);
|
||||
void load(initialPath, { host: false });
|
||||
// Only re-run when the modal actually opens - not on every initialPath
|
||||
// keystroke in the field behind it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -51,8 +183,171 @@ export function FolderBrowseModal({
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, panel, onClose);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase();
|
||||
const list = (listing?.entries ?? []).filter(
|
||||
(e) => !needle || e.name.toLowerCase().includes(needle)
|
||||
);
|
||||
const by = (a: Entry, b: Entry) =>
|
||||
sort.key === "mtime"
|
||||
? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir
|
||||
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) *
|
||||
sort.dir;
|
||||
// Folders first, as every file picker lists them.
|
||||
return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b));
|
||||
}, [listing, filter, sort]);
|
||||
|
||||
const places = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return lanes
|
||||
.filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd))
|
||||
.map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd }));
|
||||
}, [lanes]);
|
||||
|
||||
// A deep path keeps its LAST levels in view, like a native path bar.
|
||||
useEffect(() => {
|
||||
const el = crumbRef.current;
|
||||
if (el) el.scrollLeft = el.scrollWidth;
|
||||
}, [listing?.path, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const el = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-path]") ?? []).find(
|
||||
(x) => x.dataset.path === selected
|
||||
);
|
||||
el?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [selected]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const finish = (picked: string) => {
|
||||
const folder = kind === "file" ? (listing?.path ?? picked) : picked;
|
||||
const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX);
|
||||
setRecent(next);
|
||||
writeStored(RECENT_KEY, next);
|
||||
onSelect(picked);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const activate = (entry: Entry) => {
|
||||
if (entry.file) finish(entry.path);
|
||||
else void load(entry.path);
|
||||
};
|
||||
|
||||
const go = (delta: -1 | 1) => {
|
||||
const at = history.at + delta;
|
||||
const target = history.stack[at];
|
||||
if (target === undefined) return;
|
||||
void load(target, { push: false }).then((ok) => {
|
||||
if (ok) setHistory((h) => ({ ...h, at }));
|
||||
});
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
if (!listing?.parent) return;
|
||||
const from = listing.path;
|
||||
void load(listing.parent, { select: from });
|
||||
};
|
||||
|
||||
const selectedEntry = entries.find((e) => e.path === selected) ?? null;
|
||||
const target =
|
||||
kind === "file"
|
||||
? selectedEntry?.file
|
||||
? selectedEntry.path
|
||||
: null
|
||||
: selectedEntry && !selectedEntry.file
|
||||
? selectedEntry.path
|
||||
: (listing?.path ?? null);
|
||||
|
||||
const onListKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const idx = entries.findIndex((x) => x.path === selected);
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (e.altKey && e.key === "ArrowUp") return goUp();
|
||||
const next =
|
||||
e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1);
|
||||
if (entries[next]) setSelected(entries[next].path);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1];
|
||||
if (pick) setSelected(pick.path);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (selectedEntry) activate(selectedEntry);
|
||||
else if (kind === "dir" && target) finish(target);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
goUp();
|
||||
return;
|
||||
}
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Typing jumps to the first name that starts with what was typed.
|
||||
const now = Date.now();
|
||||
const ta = typeAhead.current;
|
||||
ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
|
||||
ta.at = now;
|
||||
const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text));
|
||||
if (hit) setSelected(hit.path);
|
||||
}
|
||||
};
|
||||
|
||||
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
|
||||
e.preventDefault();
|
||||
setPathText(listing?.path ?? "");
|
||||
setEditing(true);
|
||||
} else if (e.altKey && e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
go(-1);
|
||||
} else if (e.altKey && e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
go(1);
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || !listing) return;
|
||||
setError(null);
|
||||
try {
|
||||
const made = await api.lanes.mkdir(listing.path, name);
|
||||
setCreating(false);
|
||||
setNewName("");
|
||||
await load(listing.path, { push: false, select: made.path });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Switch between the admin's own home and the host account's, and open it.
|
||||
const toggleHost = () => {
|
||||
const next = !onHost;
|
||||
setOnHost(next);
|
||||
void load(undefined, { host: next });
|
||||
};
|
||||
|
||||
const toggleHidden = () => {
|
||||
const next = !showHidden;
|
||||
setShowHidden(next);
|
||||
writeStored(HIDDEN_KEY, next);
|
||||
if (listing) void load(listing.path, { push: false, hidden: next, select: selected });
|
||||
};
|
||||
|
||||
const iconBtn =
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
const placeBtn = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${
|
||||
active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||||
}`;
|
||||
const path = listing?.path ?? "";
|
||||
const floorDepth = floor ? crumbs(floor).length : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@@ -61,74 +356,362 @@ export function FolderBrowseModal({
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={onPanelKey}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("browse.title")}
|
||||
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||||
data-tour="folder-browse"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||
{listing?.path || "…"}
|
||||
{/* Toolbar: history, up, and the breadcrumb / path field. */}
|
||||
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={() => go(-1)}
|
||||
disabled={history.at <= 0}
|
||||
title={t("browse.back")}
|
||||
aria-label={t("browse.back")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={() => go(1)}
|
||||
disabled={history.at >= history.stack.length - 1}
|
||||
title={t("browse.forward")}
|
||||
aria-label={t("browse.forward")}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={goUp}
|
||||
disabled={!listing?.parent}
|
||||
title={t("browse.up")}
|
||||
aria-label={t("browse.up")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={pathText}
|
||||
onChange={(e) => setPathText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
if (pathText.trim()) void load(pathText.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditing(false)}
|
||||
aria-label={t("browse.editPath")}
|
||||
spellCheck={false}
|
||||
className="input min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={crumbRef}
|
||||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
|
||||
onDoubleClick={() => {
|
||||
setPathText(path);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{crumbs(path).map(([label, p], i, all) => {
|
||||
const last = i === all.length - 1;
|
||||
const locked = i + 1 < floorDepth;
|
||||
return (
|
||||
<span key={p} className="flex shrink-0 items-center">
|
||||
{i > 0 && all[i - 1]?.[0] !== "/" && (
|
||||
<span className="px-0.5 text-fg-muted">›</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={last || locked}
|
||||
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
|
||||
className={`rounded px-1 py-0.5 font-mono text-xs ${
|
||||
last
|
||||
? "font-medium text-fg-primary"
|
||||
: locked
|
||||
? "text-fg-muted"
|
||||
: "text-fg-secondary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setPathText(path);
|
||||
setEditing((v) => !v);
|
||||
}}
|
||||
title={t("browse.editPath")}
|
||||
aria-label={t("browse.editPath")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Places */}
|
||||
<nav
|
||||
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
|
||||
aria-label={t("browse.places")}
|
||||
>
|
||||
{home && (
|
||||
<button
|
||||
type="button"
|
||||
className={placeBtn(path === home)}
|
||||
onClick={() => void load(home)}
|
||||
title={home}
|
||||
>
|
||||
<Home className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{t("browse.home")}</span>
|
||||
</button>
|
||||
)}
|
||||
{places.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
<Star className="h-3 w-3" />
|
||||
{t("browse.workspaces")}
|
||||
</div>
|
||||
{places.map((pl) => (
|
||||
<button
|
||||
key={pl.path}
|
||||
type="button"
|
||||
className={placeBtn(path === pl.path)}
|
||||
onClick={() => void load(pl.path)}
|
||||
title={pl.path}
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate">{pl.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{recent.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("browse.recent")}
|
||||
</div>
|
||||
{recent.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={placeBtn(path === r)}
|
||||
onClick={() => void load(r)}
|
||||
title={r}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{baseName(r)}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Listing */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border p-2">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (entries[0]) setSelected(entries[0].path);
|
||||
listRef.current?.focus();
|
||||
} else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) {
|
||||
e.preventDefault();
|
||||
activate(entries[0]);
|
||||
}
|
||||
}}
|
||||
placeholder={t("browse.filter")}
|
||||
aria-label={t("browse.filter")}
|
||||
className="input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
{(["name", "mtime"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
|
||||
}
|
||||
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
|
||||
>
|
||||
{key === "name" ? t("browse.name") : t("browse.modified")}
|
||||
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
tabIndex={0}
|
||||
role="listbox"
|
||||
aria-label={path}
|
||||
onKeyDown={onListKey}
|
||||
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{creating && (
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void createFolder();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCreating(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("browse.newFolderName")}
|
||||
aria-label={t("browse.newFolderName")}
|
||||
className="input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createFolder()}
|
||||
disabled={!newName.trim()}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const isSel = entry.path === selected;
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
data-path={entry.path}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
onClick={() => setSelected(entry.path)}
|
||||
onDoubleClick={() => activate(entry)}
|
||||
className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${
|
||||
isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{entry.file ? (
|
||||
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
|
||||
) : entry.isGitRepo ? (
|
||||
<FolderGit2
|
||||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
|
||||
/>
|
||||
) : (
|
||||
<FolderOpen
|
||||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
<span
|
||||
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
|
||||
>
|
||||
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listing && entries.length === 0 && (
|
||||
<p className="p-2 text-xs text-fg-muted">
|
||||
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{listing?.parent && (
|
||||
{/* Footer: what will be picked, and the actions. */}
|
||||
<div className="border-t border-border p-2">
|
||||
<div
|
||||
// rtl so a long path loses its START to the ellipsis, not the folder.
|
||||
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
|
||||
title={target ?? undefined}
|
||||
>
|
||||
<bdi>{target ?? "…"}</bdi>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(listing.parent!)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
||||
onClick={() => {
|
||||
setNewName("");
|
||||
setCreating(true);
|
||||
}}
|
||||
disabled={!listing}
|
||||
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
..
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
{t("browse.newFolder")}
|
||||
</button>
|
||||
)}
|
||||
{listing?.entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => load(entry.path)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{listing && listing.entries.length === 0 && !listing.parent && (
|
||||
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
}}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.select")}
|
||||
</button>
|
||||
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
|
||||
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{t("browse.showHidden")}
|
||||
</label>
|
||||
{hostHome && (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
|
||||
title={t("browse.hostFolderHint", { path: hostHome })}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onHost}
|
||||
onChange={toggleHost}
|
||||
data-testid="browse-host-folder"
|
||||
/>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
{t("browse.hostFolder")}
|
||||
</label>
|
||||
)}
|
||||
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||||
{t("browse.hint")}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!target}
|
||||
onClick={() => target && finish(target)}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file InterruptedSessionsPrompt.tsx
|
||||
* @description The app-wide "resume interrupted sessions?" dialog. A session
|
||||
* whose pane died under it — the machine shut down, tmux was killed, the CLI
|
||||
* crashed — is listed by `GET /api/run/interrupted`; the user ticks the ones
|
||||
* to resume (each in the first free tab of its workspace, with its run's
|
||||
* model and effort) or resumes none. Either answer dismisses the whole list,
|
||||
* so it is asked once; a resume that failed stays listed. Refetched on the
|
||||
* `runs_interrupted` broadcast, so a tmux that dies under an open dashboard
|
||||
* asks too. Resuming is the explicit click here, never the dialog opening.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type InterruptedRun } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
|
||||
const label = (it: InterruptedRun) => it.lane_title || it.lane_cwd;
|
||||
|
||||
export function InterruptedSessionsPrompt() {
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<InterruptedRun[]>([]);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
// A refetch keeps the user's ticks; a session not seen before starts ticked.
|
||||
const seen = useRef(new Set<string>());
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.run
|
||||
.interrupted()
|
||||
.then((r) => {
|
||||
const fresh = r.items.filter((i) => !seen.current.has(i.session_id));
|
||||
fresh.forEach((i) => seen.current.add(i.session_id));
|
||||
setItems(r.items);
|
||||
setPicked((p) => {
|
||||
const next = new Set(r.items.filter((i) => p.has(i.session_id)).map((i) => i.session_id));
|
||||
fresh.forEach((i) => next.add(i.session_id));
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => undefined); // a convenience: no prompt when it cannot be read
|
||||
}, []);
|
||||
|
||||
useEffect(load, [load]);
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg) => {
|
||||
if (msg.type === "runs_interrupted") load();
|
||||
}),
|
||||
[load]
|
||||
);
|
||||
|
||||
const dismiss = async (sessionIds: string[]) => {
|
||||
if (sessionIds.length) await api.run.dismissInterrupted(sessionIds).catch(toast.error);
|
||||
};
|
||||
|
||||
const resumeNone = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
await dismiss(items.map((i) => i.session_id));
|
||||
setItems([]);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const resumePicked = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
const failed = new Set<string>();
|
||||
let first: { runId: string; laneId: number } | null = null;
|
||||
for (const it of items.filter((i) => picked.has(i.session_id))) {
|
||||
try {
|
||||
const r = await startInFreeTabDetailed(it.lane_id, {
|
||||
resumeSessionId: it.session_id,
|
||||
...(it.model ? { model: it.model } : {}),
|
||||
...(it.effort ? { effort: it.effort } : {}),
|
||||
// Bypass is a per-run choice, never carried over.
|
||||
...(it.permission_mode && it.permission_mode !== "bypassPermissions"
|
||||
? { permissionMode: it.permission_mode }
|
||||
: {}),
|
||||
});
|
||||
first ??= r;
|
||||
} catch (err) {
|
||||
failed.add(it.session_id);
|
||||
toast.error(
|
||||
t("interrupted.failed", {
|
||||
name: label(it),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await dismiss(items.map((i) => i.session_id).filter((id) => !failed.has(id)));
|
||||
setItems((cur) => cur.filter((i) => failed.has(i.session_id)));
|
||||
setBusy(false);
|
||||
if (first) navigate(`/workspace?lane=${first.laneId}&run=${encodeURIComponent(first.runId)}`);
|
||||
};
|
||||
|
||||
const toggle = (id: string, on: boolean) =>
|
||||
setPicked((p) => {
|
||||
const next = new Set(p);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const count = items.filter((i) => picked.has(i.session_id)).length;
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={items.length > 0}
|
||||
title={t("interrupted.title")}
|
||||
message={t("interrupted.message")}
|
||||
confirmLabel={t("interrupted.resume", { count })}
|
||||
cancelLabel={t("interrupted.none")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={count === 0}
|
||||
onConfirm={() => void resumePicked()}
|
||||
onCancel={() => void resumeNone()}
|
||||
>
|
||||
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
|
||||
{items.map((it) => (
|
||||
<li key={it.session_id} className="rounded-md border border-border px-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={picked.has(it.session_id)}
|
||||
onChange={(on) => toggle(it.session_id, on)}
|
||||
disabled={busy}
|
||||
className="w-full"
|
||||
labelClassName="min-w-0 flex-1"
|
||||
label={
|
||||
<span className="block min-w-0 text-left">
|
||||
<span className="block truncate text-xs text-fg-primary">
|
||||
{label(it)} · {it.tool}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-fg-muted">
|
||||
{it.session_name || it.prompt_preview || it.session_id}
|
||||
{it.ended_at ? ` · ${timeAgo(it.ended_at)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Download,
|
||||
Code2,
|
||||
Columns2,
|
||||
Eye,
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -121,6 +123,7 @@ import {
|
||||
type LaneDiffChange,
|
||||
} from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
@@ -643,6 +646,8 @@ function ChangeRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(f.path)}
|
||||
draggable
|
||||
onDragStart={(ev) => dragProjectPath(ev, f.path)}
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||
>
|
||||
<span
|
||||
@@ -805,8 +810,12 @@ export function LaneChangesPanel({
|
||||
wide = full,
|
||||
onDirtyChange,
|
||||
openRequest = null,
|
||||
filesChanged = 0,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Bumped when files changed outside this panel (the header's File
|
||||
* Explorer): the tree is read again, its open folders kept open. */
|
||||
filesChanged?: number;
|
||||
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
|
||||
openRequest?: { path: string; nonce: number } | null;
|
||||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||||
@@ -850,6 +859,9 @@ export function LaneChangesPanel({
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
// Local mode: the files are already on this machine — no upload, download
|
||||
// or File Explorer (the desktop's file manager does all three).
|
||||
const { local } = usePermissions();
|
||||
const [mode, setMode] = useState<Mode>("overview");
|
||||
const [openFile, setOpenFile] = useState<string | null>(null);
|
||||
// An image opens as a picture, never as text or a binary diff.
|
||||
@@ -865,6 +877,16 @@ export function LaneChangesPanel({
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
useEffect(() => setMdPreview(false), [openFile]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
// from the disk. Open there instead of on a page that only says "no git".
|
||||
// Both reads must say so: the facts alone also read "unavailable" on a
|
||||
// network error or an older server.
|
||||
const notRepo = git?.available === false && data?.available === false;
|
||||
useEffect(() => {
|
||||
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
|
||||
setMode(full ? "overview" : "browse");
|
||||
}, [notRepo, mode, full]);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||||
@@ -937,6 +959,13 @@ export function LaneChangesPanel({
|
||||
const [message, setMessage] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// The last commit is already on its upstream: the server refuses to amend
|
||||
// it (EPUSHED), so the box is not offered — asking first and failing after
|
||||
// the confirm only costs the user a click.
|
||||
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
|
||||
useEffect(() => {
|
||||
if (headPushed) setAmend(false);
|
||||
}, [headPushed]);
|
||||
// The files the commit takes (JetBrains' checkboxes). A path listed for the
|
||||
// first time arrives with its default; `seenPaths` is the last full list,
|
||||
// so a poll never re-ticks a file the user unticked.
|
||||
@@ -1066,19 +1095,32 @@ export function LaneChangesPanel({
|
||||
}, [mode, laneId, query, reread, full, treeRootLoaded]);
|
||||
|
||||
// A folder's children are fetched lazily, the first time it is expanded.
|
||||
const readDir = (path: string) =>
|
||||
api.lanes
|
||||
.gitTree(target, path)
|
||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
const toggleDir = (path: string) => {
|
||||
const opening = !expandedDirs.has(path);
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(path)) next.add(path);
|
||||
return next;
|
||||
});
|
||||
if (path in treeChildren) return;
|
||||
void api.lanes
|
||||
.gitTree(target, path)
|
||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
// Every OPEN reads the folder again (the cached level shows meanwhile):
|
||||
// files land in it from outside the panel — an agent, the terminal, the
|
||||
// File Explorer — and a level cached while it was empty stayed empty.
|
||||
if (opening) void readDir(path);
|
||||
};
|
||||
|
||||
// Files changed outside the panel: read the root and every open folder again.
|
||||
useEffect(() => {
|
||||
if (!filesChanged) return;
|
||||
void readDir("");
|
||||
for (const p of expandedDirs) void readDir(p);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesChanged]);
|
||||
|
||||
const [treeBusy, setTreeBusy] = useState(false);
|
||||
|
||||
// "Expand all" is ONE read of the whole tree: a request per folder meant a
|
||||
@@ -1612,8 +1654,32 @@ export function LaneChangesPanel({
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
||||
{t("changes.showHistory")}
|
||||
</MenuItem>
|
||||
{!local && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-download"
|
||||
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.download")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!local && treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-upload"
|
||||
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.uploadHere")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{!local && treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-explorer"
|
||||
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
|
||||
>
|
||||
{t("changes.openInExplorer")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||||
{t("changes.copyPath")}
|
||||
</MenuItem>
|
||||
@@ -1859,6 +1925,61 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
// Files tab transfers (issue #78): a download is a plain attachment link; an
|
||||
// upload only creates, into the folder it was asked for ("" = the root).
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
const uploadDir = useRef("");
|
||||
const [, setUploading] = useState(false);
|
||||
// The File Explorer (the toolbar button where Upload was); the tree is
|
||||
// re-read when it closes after changing anything.
|
||||
const [explorer, setExplorer] = useState<{ path: string } | null>(null);
|
||||
const explorerChanged = useRef(false);
|
||||
const download = (path: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = api.lanes.gitDownloadUrl(target, path);
|
||||
a.download = path.split("/").pop() || path;
|
||||
a.click();
|
||||
};
|
||||
const pickUpload = (dir: string) => {
|
||||
uploadDir.current = dir;
|
||||
uploadInput.current?.click();
|
||||
};
|
||||
const upload = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
const lane = laneId;
|
||||
const dir = uploadDir.current;
|
||||
setUploading(true);
|
||||
let saved = 0;
|
||||
try {
|
||||
for (const f of files) {
|
||||
try {
|
||||
await api.lanes.gitUpload(target, dir, f);
|
||||
saved++;
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("changes.uploadFailed", {
|
||||
name: f.name,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (laneNow.current === lane) {
|
||||
setUploading(false);
|
||||
if (saved) {
|
||||
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
|
||||
// Re-read the level it landed in, and open it.
|
||||
void api.lanes
|
||||
.gitTree(target, dir)
|
||||
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
|
||||
.catch(() => undefined);
|
||||
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCollapsed = (path: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -2045,6 +2166,36 @@ export function LaneChangesPanel({
|
||||
aria-label={t("changes.quickOpen")}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
{/* The File Explorer's button lives in the panel header now (beside
|
||||
Full screen); this instance serves the tree's "Open in File
|
||||
Explorer". */}
|
||||
<FileExplorer
|
||||
open={!!explorer}
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
initialPath={explorer?.path ?? ""}
|
||||
onChanged={() => (explorerChanged.current = true)}
|
||||
onClose={() => {
|
||||
setExplorer(null);
|
||||
if (explorerChanged.current) {
|
||||
explorerChanged.current = false;
|
||||
void rereadAll();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={uploadInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="lane-changes-upload-input"
|
||||
onChange={(e) => {
|
||||
const files = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
void upload(files);
|
||||
}}
|
||||
/>
|
||||
{!hits && (
|
||||
<IconButton
|
||||
label={
|
||||
@@ -2146,14 +2297,16 @@ export function LaneChangesPanel({
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t("changes.back")}
|
||||
</button>
|
||||
) : (
|
||||
) : notRepo && full ? null : (
|
||||
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{/* Files is the whole repo's tree; at full width it is the
|
||||
sidebar already, so no button. */}
|
||||
{(full
|
||||
? (["overview", "review", "history"] as const)
|
||||
: (["overview", "browse", "review", "history"] as const)
|
||||
).map((m) => (
|
||||
{(notRepo
|
||||
? (["browse"] as const)
|
||||
: full
|
||||
? (["overview", "review", "history"] as const)
|
||||
: (["overview", "browse", "review", "history"] as const)
|
||||
).map((m: Mode) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
@@ -2508,7 +2661,9 @@ export function LaneChangesPanel({
|
||||
browsePane
|
||||
) : !data ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !data.available ? (
|
||||
) : !data.available && mode !== "file" ? (
|
||||
// A file still opens from Files: its whole-file read is the disk's,
|
||||
// not git's.
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
|
||||
</p>
|
||||
@@ -2564,13 +2719,16 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<label
|
||||
title={t("changes.amendHint")}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
|
||||
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
|
||||
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
|
||||
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="lane-changes-amend"
|
||||
checked={amend}
|
||||
disabled={headPushed}
|
||||
onChange={(e) => setAmend(e.target.checked)}
|
||||
/>
|
||||
{t("changes.amend")}
|
||||
@@ -2756,6 +2914,18 @@ export function LaneChangesPanel({
|
||||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{!local && range !== "commit" && openFile && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-download"
|
||||
onClick={() => download(openFile)}
|
||||
title={t("changes.download")}
|
||||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{t("changes.download")}
|
||||
</button>
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{/* A working file opens in the editor; the read-only diff
|
||||
(Whole file off) keeps an Edit button back into it. */}
|
||||
@@ -2836,7 +3006,9 @@ export function LaneChangesPanel({
|
||||
collapse={!whole}
|
||||
revertLabel={t("changes.revertBlock")}
|
||||
value={draft}
|
||||
original={baseText ?? draft}
|
||||
// Outside a repo there is no HEAD: the edits are
|
||||
// shown against the text as it was opened.
|
||||
original={(notRepo ? text?.lines.join("\n") : baseText) ?? draft}
|
||||
lang={langFor(openFile ?? "")}
|
||||
label={t("changes.editorLabel", { path: openFile })}
|
||||
onChange={setDraft}
|
||||
|
||||
@@ -242,13 +242,27 @@ export function LaneGitBar({
|
||||
void run(action, body);
|
||||
}
|
||||
};
|
||||
const push = () =>
|
||||
confirmRun(
|
||||
const push = async () => {
|
||||
let ahead = git.ahead ?? 0;
|
||||
if (git.upstream && ahead === 0) {
|
||||
// The facts can be up to 30 s old (an agent may have just committed):
|
||||
// ask git again before saying there is nothing to push — and never
|
||||
// ask to confirm pushing zero commits.
|
||||
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
|
||||
const fresh = read?.available ? read : null;
|
||||
ahead = fresh?.ahead ?? 0;
|
||||
if (fresh?.upstream && ahead === 0) {
|
||||
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
|
||||
onChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return confirmRun(
|
||||
git.upstream
|
||||
? {
|
||||
title: t("gitBar.push"),
|
||||
message: t("gitBar.pushConfirm", {
|
||||
count: git.ahead ?? 0,
|
||||
count: ahead,
|
||||
branch: git.branch,
|
||||
upstream: git.upstream,
|
||||
}),
|
||||
@@ -259,6 +273,7 @@ export function LaneGitBar({
|
||||
},
|
||||
"push"
|
||||
);
|
||||
};
|
||||
const publish = () =>
|
||||
confirmRun(
|
||||
{
|
||||
|
||||
@@ -4,20 +4,21 @@
|
||||
* the sidebar's **New session** row). Every item is something the row already
|
||||
* does: open it the way its click does (a live pane attaches, an ended session
|
||||
* opens its conversation — never a resume), open a live one in a split,
|
||||
* rename / archive / unarchive when the list offers them, copy its id, open
|
||||
* 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).
|
||||
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
|
||||
* its analysis page, and end its live tab as the tab's buttons do (`endRuns`,
|
||||
* after the same confirm for an AI tab, which may be mid-turn): **Sleep** stops
|
||||
* it and keeps the session listed to resume later, **Close tab** stops it and
|
||||
* archives the session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { canSleep, endRuns } from "../../lib/stopSession";
|
||||
|
||||
export function SessionMenu({
|
||||
laneId,
|
||||
@@ -29,6 +30,7 @@ export function SessionMenu({
|
||||
onRename,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onPin,
|
||||
}: MenuPoint & {
|
||||
laneId: number;
|
||||
session: Session | null;
|
||||
@@ -38,6 +40,8 @@ export function SessionMenu({
|
||||
onRename?: () => void;
|
||||
onArchive?: () => void;
|
||||
onUnarchive?: () => void;
|
||||
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||
onPin?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -47,24 +51,12 @@ export function SessionMenu({
|
||||
onClose();
|
||||
fn();
|
||||
};
|
||||
const closeTab = async () => {
|
||||
// Stop archives the session; Sleep keeps it listed to resume later
|
||||
// (`endRuns` asks first for an AI pane, which may be mid-turn).
|
||||
const endTab = (sleep: boolean) => {
|
||||
if (!run) return;
|
||||
onClose();
|
||||
if (
|
||||
run.tool !== "shell" &&
|
||||
!(await confirmDialog({
|
||||
title: t("tabs.stopTitle", { name: session?.name || t("tree.newSession") }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(run.id);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
void endRuns([run], { sleep, name: session?.name || t("tree.newSession"), t });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -84,6 +76,11 @@ export function SessionMenu({
|
||||
</MenuItem>
|
||||
)}
|
||||
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
|
||||
{onPin && session && (
|
||||
<MenuItem onSelect={pick(onPin)}>
|
||||
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
@@ -97,8 +94,13 @@ export function SessionMenu({
|
||||
{(run || onArchive || onUnarchive) && <MenuSeparator />}
|
||||
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
|
||||
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
|
||||
{canSleep(run) && (
|
||||
<MenuItem testId="session-menu-sleep" onSelect={() => endTab(true)}>
|
||||
{t("tabs.sleep")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{run && (
|
||||
<MenuItem danger onSelect={() => void closeTab()}>
|
||||
<MenuItem danger onSelect={() => endTab(false)}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
@@ -121,7 +121,11 @@ export function SessionReplay({
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div
|
||||
data-testid="session-replay"
|
||||
data-prompt-drop
|
||||
className="flex min-h-0 flex-1 flex-col gap-2"
|
||||
>
|
||||
{session && (
|
||||
<div className="flex justify-end">
|
||||
<SessionResumeButton session={session} />
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, Folder, Pencil, Plus, X } from "lucide-react";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { CloneSourcesForm } from "./CloneSourcesForm";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */
|
||||
export function WorkspaceContext(props: Parameters<typeof ContextBody>[0]) {
|
||||
return <ContextBody key={props.lane.id} {...props} />;
|
||||
}
|
||||
|
||||
function ContextBody({
|
||||
lane,
|
||||
onSourcesChange,
|
||||
sourcesVersion = 0,
|
||||
compact = false,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Called with the sources after a folder or file is added or removed. */
|
||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||
/** Bumped when the folder list changed elsewhere: the lists re-read. */
|
||||
sourcesVersion?: number;
|
||||
/** The Changes panel's narrow fold: no card, tighter rows. */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const aiTools = useAiTools();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const [memoryTab, setMemoryTab] = useState(0);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [ctxDrag, setCtxDrag] = useState(false);
|
||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
const sourceCtx = useContextMenu<LaneSource>();
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch((err) => live && setError(message(err)));
|
||||
api.lanes
|
||||
.memory(lane.id)
|
||||
.then((r) => live && setMemory(r.files))
|
||||
.catch(() => live && setMemory([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// Suggestions are read when the folder form opens, not on every visit.
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
let live = true;
|
||||
api.projects
|
||||
.list()
|
||||
.then(({ projects }) => {
|
||||
if (!live) return;
|
||||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||||
setSuggestions(
|
||||
!p || !prefs.showUntracked
|
||||
? []
|
||||
: p.worktrees
|
||||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||||
.map((w) => w.path)
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourcesVersion) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
||||
}, [sourcesVersion]);
|
||||
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
const next = await fn();
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const addFiles = async (picked: File[]) => {
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
};
|
||||
|
||||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||||
const card = compact
|
||||
? "border-b border-border/60 px-1 py-2 last:border-b-0"
|
||||
: "border-b border-border px-4 py-4 last:border-b-0";
|
||||
const heading = compact
|
||||
? "text-xs font-medium text-fg-secondary"
|
||||
: "text-sm font-medium text-fg-primary";
|
||||
const iconBtn =
|
||||
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="border-b border-border px-4 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.instructions")}</h3>
|
||||
{editing === null && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.edit")}
|
||||
title={t("home.edit")}
|
||||
onClick={() => setEditing(view?.instructions ?? "")}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing === null ? (
|
||||
<p
|
||||
className={`mt-1 ${compact ? "line-clamp-2" : "line-clamp-3"} whitespace-pre-wrap text-xs text-fg-muted`}
|
||||
>
|
||||
{view?.instructions || t("home.instructionsEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
<textarea
|
||||
data-testid="workspace-instructions"
|
||||
aria-label={t("home.instructions")}
|
||||
autoFocus
|
||||
value={editing}
|
||||
maxLength={view?.maxInstructions}
|
||||
onChange={(e) => setEditing(e.target.value)}
|
||||
rows={compact ? 4 : 6}
|
||||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("home.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||||
setEditing(null);
|
||||
}}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("home.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.memory")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMemoryTab(0);
|
||||
setMemoryOpen(true);
|
||||
}}
|
||||
disabled={!memory?.length}
|
||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{t("home.viewMemory")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{memory?.length
|
||||
? t("home.memoryCount", { count: memory.length })
|
||||
: t("home.memoryEmpty")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
data-testid="workspace-context-files"
|
||||
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||||
// Files dragged in from the OS are added like the + button's pick.
|
||||
onDragOver={(e) => {
|
||||
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setCtxDrag(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
setCtxDrag(false);
|
||||
if (!view || e.dataTransfer.files.length === 0) return;
|
||||
e.preventDefault();
|
||||
void addFiles([...e.dataTransfer.files]);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.context")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFile")}
|
||||
title={t("home.addFile")}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
data-testid="workspace-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
void addFiles(picked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!compact && <p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>}
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className={`mt-2 w-full rounded-lg border border-dashed border-border px-3 ${compact ? "py-2" : "py-5"} text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary`}
|
||||
>
|
||||
{t("home.contextEmpty")}
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
// Dragged onto a prompt box, it is attached as `@<path>`.
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, f.path)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">
|
||||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.folder")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFolder")}
|
||||
title={t("home.addFolder")}
|
||||
onClick={() => setFolderOpen((v) => !v)}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<li
|
||||
className="flex items-start gap-2 text-xs"
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, `${lane.cwd}/`)}
|
||||
>
|
||||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0">
|
||||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||||
</span>
|
||||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||||
</span>
|
||||
</li>
|
||||
{folders.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, `${f.path}/`)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
{f.missing && (
|
||||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{folderOpen && (
|
||||
<form
|
||||
className="mt-3 space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||||
setFolderPath("");
|
||||
setFolderOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="workspace-folder-input"
|
||||
aria-label={t("home.folderPath")}
|
||||
placeholder={t("home.folderPath")}
|
||||
autoFocus
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||||
{t("home.add")}
|
||||
</button>
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("home.suggested")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{suggestions.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFolderPath(p)}
|
||||
title={p}
|
||||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{folderOpen && (
|
||||
<CloneSourcesForm
|
||||
laneId={lane.id}
|
||||
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
|
||||
onAdded={(next) => {
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{memoryOpen && memory && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
>
|
||||
<div
|
||||
ref={memoryPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("home.memory")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
aria-label={t("home.close")}
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("home.memory")}
|
||||
// Wraps, so every file is reachable without a horizontal wheel (a
|
||||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||||
>
|
||||
{memory.map((f, i) => (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`workspace-memory-tab-${i}`}
|
||||
aria-controls="workspace-memory-panel"
|
||||
tabIndex={i === memoryTab ? 0 : -1}
|
||||
title={f.path}
|
||||
aria-selected={i === memoryTab}
|
||||
onClick={() => setMemoryTab(i)}
|
||||
// APG tabs: arrows move (and select), one tab stop.
|
||||
onKeyDown={(e) => {
|
||||
const n = memory.length;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % n
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + n) % n
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? n - 1
|
||||
: null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
setMemoryTab(to);
|
||||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||||
}}
|
||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
i === memoryTab
|
||||
? "border-accent/30 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{memory[memoryTab] && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="workspace-memory-panel"
|
||||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<MarkdownPreview text={memory[memoryTab].content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
void copyText(sourceCtx.menu!.item.path);
|
||||
sourceCtx.close();
|
||||
}}
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const id = sourceCtx.menu!.item.id;
|
||||
sourceCtx.close();
|
||||
void run(() => api.lanes.removeSource(lane.id, id));
|
||||
}}
|
||||
>
|
||||
{t("home.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,40 +16,30 @@
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArchiveRestore,
|
||||
ShieldAlert,
|
||||
FileText,
|
||||
Folder,
|
||||
GitBranch,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type CodexApproval,
|
||||
type CodexSandbox,
|
||||
type EffortLevel,
|
||||
type LaneMemoryFile,
|
||||
type LaneSource,
|
||||
type LaneSourcesView,
|
||||
type ModelChoice,
|
||||
type PermissionMode,
|
||||
@@ -58,18 +48,16 @@ import {
|
||||
import type { Lane, Project, Session } from "../../lib/types";
|
||||
import { fmtCost, timeAgo } from "../../lib/format";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { ChatAddMenu } from "../run/ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
import { workspaceName } from "./WorkspaceRow";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
|
||||
import { menuPoint, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
liveRunFor,
|
||||
SessionFold,
|
||||
@@ -87,16 +75,9 @@ export {
|
||||
startInFreeTabDetailed,
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Recents' page size; Show more adds another. */
|
||||
const RECENT_PAGE = 30;
|
||||
|
||||
@@ -135,7 +116,6 @@ function HomeBody({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const prefs = useProjectTreePrefs();
|
||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||
const [tool, setTool] = useState(lane.tool);
|
||||
const entry = aiTools.byId(tool);
|
||||
@@ -209,27 +189,9 @@ function HomeBody({
|
||||
// A Recents row being renamed (its session id) and the draft.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const [memoryTab, setMemoryTab] = useState(0);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [ctxDrag, setCtxDrag] = useState(false);
|
||||
const addFiles = async (picked: File[]) => {
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
};
|
||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
const sourceCtx = useContextMenu<LaneSource>();
|
||||
|
||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||||
@@ -258,25 +220,6 @@ function HomeBody({
|
||||
`${runKey}:${version}`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setView(null);
|
||||
setMemory(null);
|
||||
setEditing(null);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch((err) => live && setError(message(err)));
|
||||
api.lanes
|
||||
.memory(lane.id)
|
||||
.then((r) => live && setMemory(r.files))
|
||||
.catch(() => live && setMemory([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// The repository this workspace belongs to (Settings → Projects used to
|
||||
// show this): read once per visit, from the server's cached project list.
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
@@ -293,57 +236,6 @@ function HomeBody({
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// Suggestions are read when the folder form opens, not on every visit.
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
let live = true;
|
||||
api.projects
|
||||
.list()
|
||||
.then(({ projects }) => {
|
||||
if (!live) return;
|
||||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||||
setSuggestions(
|
||||
!p || !prefs.showUntracked
|
||||
? []
|
||||
: p.worktrees
|
||||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||||
.map((w) => w.path)
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourcesVersion) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
||||
}, [sourcesVersion]);
|
||||
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
const next = await fn();
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
||||
// at once — the same start the session view's tab bar `+` makes.
|
||||
const openTerminal = async () => {
|
||||
@@ -385,6 +277,13 @@ function HomeBody({
|
||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||
});
|
||||
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));
|
||||
setImages([]);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
@@ -466,10 +365,7 @@ function HomeBody({
|
||||
? t("run:hint.sandboxWarning")
|
||||
: null;
|
||||
|
||||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||||
const name = workspaceName(lane);
|
||||
const card = "border-b border-border px-4 py-4 last:border-b-0";
|
||||
const iconBtn =
|
||||
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
@@ -478,7 +374,7 @@ function HomeBody({
|
||||
data-testid="workspace-home"
|
||||
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 basis-[28rem]">
|
||||
<div data-prompt-drop className="min-w-0 flex-1 basis-[28rem]">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
|
||||
</p>
|
||||
@@ -838,363 +734,11 @@ function HomeBody({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className="card w-80 shrink-0 self-start p-0"
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
|
||||
{editing === null && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.edit")}
|
||||
title={t("home.edit")}
|
||||
onClick={() => setEditing(view?.instructions ?? "")}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing === null ? (
|
||||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
|
||||
{view?.instructions || t("home.instructionsEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
<textarea
|
||||
data-testid="workspace-instructions"
|
||||
aria-label={t("home.instructions")}
|
||||
autoFocus
|
||||
value={editing}
|
||||
maxLength={view?.maxInstructions}
|
||||
onChange={(e) => setEditing(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("home.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||||
setEditing(null);
|
||||
}}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("home.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMemoryTab(0);
|
||||
setMemoryOpen(true);
|
||||
}}
|
||||
disabled={!memory?.length}
|
||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{t("home.viewMemory")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{memory?.length
|
||||
? t("home.memoryCount", { count: memory.length })
|
||||
: t("home.memoryEmpty")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
data-testid="workspace-context-files"
|
||||
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||||
// Files dragged in from the OS are added like the + button's pick.
|
||||
onDragOver={(e) => {
|
||||
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setCtxDrag(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
setCtxDrag(false);
|
||||
if (!view || e.dataTransfer.files.length === 0) return;
|
||||
e.preventDefault();
|
||||
void addFiles([...e.dataTransfer.files]);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFile")}
|
||||
title={t("home.addFile")}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
data-testid="workspace-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
void addFiles(picked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
|
||||
>
|
||||
{t("home.contextEmpty")}
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">
|
||||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFolder")}
|
||||
title={t("home.addFolder")}
|
||||
onClick={() => setFolderOpen((v) => !v)}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<li className="flex items-start gap-2 text-xs">
|
||||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0">
|
||||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||||
</span>
|
||||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||||
</span>
|
||||
</li>
|
||||
{folders.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
{f.missing && (
|
||||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{folderOpen && (
|
||||
<form
|
||||
className="mt-3 space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||||
setFolderPath("");
|
||||
setFolderOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="workspace-folder-input"
|
||||
aria-label={t("home.folderPath")}
|
||||
placeholder={t("home.folderPath")}
|
||||
autoFocus
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||||
{t("home.add")}
|
||||
</button>
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("home.suggested")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{suggestions.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFolderPath(p)}
|
||||
title={p}
|
||||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{memoryOpen && memory && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
>
|
||||
<div
|
||||
ref={memoryPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("home.memory")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
aria-label={t("home.close")}
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("home.memory")}
|
||||
// Wraps, so every file is reachable without a horizontal wheel (a
|
||||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||||
>
|
||||
{memory.map((f, i) => (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`workspace-memory-tab-${i}`}
|
||||
aria-controls="workspace-memory-panel"
|
||||
tabIndex={i === memoryTab ? 0 : -1}
|
||||
title={f.path}
|
||||
aria-selected={i === memoryTab}
|
||||
onClick={() => setMemoryTab(i)}
|
||||
// APG tabs: arrows move (and select), one tab stop.
|
||||
onKeyDown={(e) => {
|
||||
const n = memory.length;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % n
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + n) % n
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? n - 1
|
||||
: null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
setMemoryTab(to);
|
||||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||||
}}
|
||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
i === memoryTab
|
||||
? "border-accent/30 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{memory[memoryTab] && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="workspace-memory-panel"
|
||||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<MarkdownPreview text={memory[memoryTab].content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<WorkspaceContext
|
||||
lane={lane}
|
||||
onSourcesChange={onSourcesChange}
|
||||
sourcesVersion={sourcesVersion}
|
||||
/>
|
||||
|
||||
{recentCtx.menu && (
|
||||
<SessionMenu
|
||||
@@ -1212,34 +756,6 @@ function HomeBody({
|
||||
onArchive={() => void archiveSession(recentCtx.menu!.item)}
|
||||
/>
|
||||
)}
|
||||
{sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
void copyText(sourceCtx.menu!.item.path);
|
||||
sourceCtx.close();
|
||||
}}
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const id = sourceCtx.menu!.item.id;
|
||||
sourceCtx.close();
|
||||
void run(() => api.lanes.removeSource(lane.id, id));
|
||||
}}
|
||||
>
|
||||
{t("home.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{menuAt && (
|
||||
<WorkspaceRowMenu
|
||||
lane={lane}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
|
||||
* pane above the ended ones) — never the other
|
||||
* (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
|
||||
* pinned ones on top in pin order, then the ones with a live pane, then the
|
||||
* ended ones; a pin is `PATCH {pinned}`, issue #80) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* renames it, its hover buttons pin/unpin and archive it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing); a right-click opens the same actions and more
|
||||
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
@@ -33,7 +34,7 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
@@ -278,17 +279,21 @@ export function WorkspaceSessionList({
|
||||
3000
|
||||
);
|
||||
const refreshKey = `${hookKey}:${version}`;
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const own = useSessionPage(
|
||||
{ lane: lane.id, archived: false, pinnedFirst: true, limit },
|
||||
refreshKey
|
||||
);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||
// Live sessions first, ended ones under them — each group stays newest
|
||||
// first (the sort is stable over the server's order).
|
||||
// Pinned sessions first, then live ones, then ended ones — each group in
|
||||
// the server's order (pin order, else newest first; the sort is stable).
|
||||
const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2);
|
||||
const shown = (sessions ?? [])
|
||||
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
|
||||
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
// A live tab whose session has no row yet: shown as a New session at the
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
@@ -336,7 +341,10 @@ export function WorkspaceSessionList({
|
||||
if (count !== null) onMatches?.(count);
|
||||
}, [count, onMatches]);
|
||||
|
||||
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||||
const patch = async (
|
||||
s: Session,
|
||||
body: { name?: string; archived?: boolean; pinned?: boolean }
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.sessions.patch(s.id, body);
|
||||
@@ -533,6 +541,13 @@ export function WorkspaceSessionList({
|
||||
/>
|
||||
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
{s.pinned_at && (
|
||||
<Pin
|
||||
data-testid={`workspace-session-pinned-${s.id}`}
|
||||
aria-label={t("tree.pinned")}
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
{sessionWorktreeName(lane, s) && (
|
||||
<GitBranch
|
||||
data-testid={`workspace-session-worktree-${s.id}`}
|
||||
@@ -540,7 +555,9 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
<span
|
||||
className={`ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
>
|
||||
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
@@ -548,6 +565,22 @@ export function WorkspaceSessionList({
|
||||
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
/>
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-pin-${s.id}`}
|
||||
onClick={() => void patch(s, { pinned: !s.pinned_at })}
|
||||
title={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
|
||||
aria-label={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
|
||||
className="absolute right-6 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
{s.pinned_at ? (
|
||||
<PinOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -608,6 +641,9 @@ export function WorkspaceSessionList({
|
||||
onClose={sessionCtx.close}
|
||||
onRename={() => startRename(sessionCtx.menu!.item)}
|
||||
onArchive={() => void archive(sessionCtx.menu!.item)}
|
||||
onPin={() =>
|
||||
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,8 +25,11 @@ vi.mock("../../../lib/api", () => ({
|
||||
pipelines: vi.fn(),
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
list: vi.fn(() => Promise.resolve({ lanes: [], counts: {} })),
|
||||
get: vi.fn(),
|
||||
profileInit: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
@@ -154,6 +157,7 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -582,17 +586,15 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the picker on every click into the path field", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
|
||||
it("a click into the path field only focuses it — no picker opens (#77)", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
const field = document.getElementById("add-lane-repo")!;
|
||||
|
||||
await user.click(field);
|
||||
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
|
||||
await user.click(field);
|
||||
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
|
||||
expect(field).toHaveFocus();
|
||||
expect(api.lanes.pickPath).not.toHaveBeenCalled();
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
||||
@@ -614,7 +616,7 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||
it("opens the browser, lists subdirectories, and a double-click opens one", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
@@ -627,8 +629,8 @@ describe("AddLaneModal — folder browse", () => {
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
||||
await user.dblClick(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false, false);
|
||||
});
|
||||
|
||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||
@@ -668,46 +670,18 @@ describe("AddLaneModal — create/update readme", () => {
|
||||
describe("AddLaneModal — no proxy here", () => {
|
||||
it("asks for no AI Proxy: a new session picks its own", async () => {
|
||||
renderModal({});
|
||||
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
|
||||
expect(api.aiProxies.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — tool selection", () => {
|
||||
it("sends the picked tool when adopting a repo", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/tmp/demo");
|
||||
// One button showing the default; it opens every installed + enabled tool.
|
||||
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
expect(picker).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(picker);
|
||||
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
).toEqual(["Claude Code", "Codex", "Gemini"]);
|
||||
await user.click(within(list).getByRole("button", { name: "Codex" }));
|
||||
// The picked tool replaces the default on the button, and the list closes.
|
||||
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
|
||||
);
|
||||
it("offers no tool picker: a new session picks its own AI", async () => {
|
||||
renderModal({});
|
||||
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: /Claude Code/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Codex" })).toBeNull();
|
||||
});
|
||||
|
||||
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
|
||||
@@ -779,3 +753,76 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — Clone", () => {
|
||||
async function fillClone(user: ReturnType<typeof userEvent.setup>, url: string) {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, url);
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
}
|
||||
|
||||
it("clones into <folder>/<repo name> and reports the new workspace", async () => {
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 5 }),
|
||||
created: true,
|
||||
path: "/home/an/code/repo",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
expect(screen.getByText(/cloned to \/home\/an\/code\/repo/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "git@github.com:owner/repo.git", parent: "/home/an/code" })
|
||||
)
|
||||
);
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("needs a login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "git.example.com" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 6 }), created: true, path: "/x" });
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "https://git.example.com/o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
const login = await screen.findByTestId("add-lane-clone-login");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("git.example.com needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "an");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[1]?.[0].credentials).toEqual({
|
||||
username: "an",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("points to Settings → Git when the SSH key is refused", async () => {
|
||||
vi.mocked(api.lanes.clone).mockRejectedValue(
|
||||
Object.assign(new Error("refused"), { code: "ESSHAUTH" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "git@github.com:o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
const link = await screen.findByRole("link", { name: "Open Settings → Git" });
|
||||
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||
|
||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
|
||||
if (body?.url.includes("private") && !body.credentials)
|
||||
throw Object.assign(new Error("login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "private.test" },
|
||||
});
|
||||
return view;
|
||||
});
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("workspace-clone-input"),
|
||||
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
|
||||
await user.type(screen.getByLabelText("Username"), "dev");
|
||||
await user.type(screen.getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
// Inline at the end of the first selected line, never a tooltip over the
|
||||
// line above it.
|
||||
expect(chip.closest(".cm-tooltip")).toBeNull();
|
||||
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
||||
// A tooltip above the last selected line, never inline at a line's end
|
||||
// (a long line pushed it out of view).
|
||||
expect(chip.classList.contains("cm-tooltip")).toBe(true);
|
||||
expect(chip.closest(".cm-line")).toBeNull();
|
||||
fireEvent.click(chip);
|
||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* @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, restores from the user's own
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
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";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
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(),
|
||||
allTrash: 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.allTrash).mockResolvedValue({ items: [], errors: [] });
|
||||
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(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined)
|
||||
);
|
||||
});
|
||||
|
||||
it("opens an admin's Trash on one folder per user, each opening that user's Trash", async () => {
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
home: "bob",
|
||||
owner: "Bob",
|
||||
},
|
||||
],
|
||||
errors: [{ home: "me", owner: "An", message: "EMEMBERGONE" }],
|
||||
});
|
||||
vi.mocked(api.files.trash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const trashes = screen.getByTestId("fx-trashes");
|
||||
expect(within(trashes).getAllByRole("button")).toHaveLength(1);
|
||||
await user.click(within(trashes).getByRole("button", { name: "Trash" }));
|
||||
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||
expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]);
|
||||
expect(screen.queryByTestId("fx-trash-row")).toBeNull();
|
||||
await user.click(folders[1]!);
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" }));
|
||||
expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob");
|
||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Bob's Trash") })
|
||||
)
|
||||
);
|
||||
expect(api.files.purge).not.toHaveBeenCalled();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||
);
|
||||
// Back to the folders through the path bar.
|
||||
await user.click(
|
||||
within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", {
|
||||
name: "Trash",
|
||||
})
|
||||
);
|
||||
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
home: "bob",
|
||||
owner: "Bob",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
vi.mocked(api.files.trash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||
// My Trash is empty: its Empty Trash is disabled.
|
||||
fireEvent.contextMenu(folders[0]!);
|
||||
expect(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||
name: "Empty Trash",
|
||||
})
|
||||
).toHaveProperty("disabled", true);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.contextMenu(folders[1]!);
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||
name: "Empty Trash",
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
|
||||
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
|
||||
|
||||
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
|
||||
);
|
||||
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||
);
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
|
||||
);
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
|
||||
);
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||
});
|
||||
|
||||
it("shows why a Trash could not be read instead of an empty one", async () => {
|
||||
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||
expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy();
|
||||
expect(screen.queryByText("The Trash is empty.")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file InterruptedSessionsPrompt.test.tsx
|
||||
* @description The "resume interrupted sessions?" dialog: it opens only when
|
||||
* the server lists some, resumes only the ticked ones (never a bypass mode
|
||||
* carried over), and either answer dismisses the list — but a resume that
|
||||
* failed stays offered.
|
||||
* @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 { MemoryRouter } from "react-router-dom";
|
||||
import type { InterruptedRun } from "../../../lib/api";
|
||||
|
||||
vi.mock("../startSession", () => ({
|
||||
startInFreeTabDetailed: vi.fn(async (laneId: number) => ({
|
||||
runId: `agenthub-lane-${laneId}-1`,
|
||||
laneId,
|
||||
held: false,
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
run: {
|
||||
interrupted: vi.fn(),
|
||||
dismissInterrupted: vi.fn(async () => ({ dismissed: 0 })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { startInFreeTabDetailed } from "../startSession";
|
||||
import { InterruptedSessionsPrompt } from "../InterruptedSessionsPrompt";
|
||||
|
||||
const row = (session_id: string, lane_id: number, extra: Partial<InterruptedRun> = {}) =>
|
||||
({
|
||||
id: `agenthub-lane-${lane_id}`,
|
||||
session_id,
|
||||
lane_id,
|
||||
tool: "claude",
|
||||
cwd: "/r",
|
||||
model: null,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
prompt_preview: `prompt ${session_id}`,
|
||||
started_at: "2026-10-10T00:00:00Z",
|
||||
ended_at: "2026-10-10T01:00:00Z",
|
||||
lane_title: `ws-${lane_id}`,
|
||||
lane_cwd: "/r",
|
||||
session_name: null,
|
||||
...extra,
|
||||
}) as InterruptedRun;
|
||||
|
||||
const list = (items: InterruptedRun[]) =>
|
||||
vi.mocked(api.run.interrupted).mockResolvedValue({ items });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const renderIt = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<InterruptedSessionsPrompt />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("InterruptedSessionsPrompt", () => {
|
||||
it("shows nothing when no session was interrupted", async () => {
|
||||
list([]);
|
||||
renderIt();
|
||||
await waitFor(() => expect(api.run.interrupted).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("resumes only the ticked sessions, then dismisses the list", async () => {
|
||||
list([row("s-a", 1, { permission_mode: "bypassPermissions", model: "opus" }), row("s-b", 2)]);
|
||||
const user = userEvent.setup();
|
||||
renderIt();
|
||||
await screen.findByRole("dialog");
|
||||
await user.click(screen.getByRole("checkbox", { name: /ws-2/ }));
|
||||
await user.click(screen.getByRole("button", { name: /resume 1/i }));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a", "s-b"]));
|
||||
expect(startInFreeTabDetailed).toHaveBeenCalledTimes(1);
|
||||
// The model goes back; a bypass mode is never carried over.
|
||||
expect(startInFreeTabDetailed).toHaveBeenCalledWith(1, {
|
||||
resumeSessionId: "s-a",
|
||||
model: "opus",
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("resumes nothing on Don't resume, and keeps a session whose resume failed", async () => {
|
||||
list([row("s-a", 1)]);
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderIt();
|
||||
await user.click(await screen.findByText("Don't resume"));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a"]));
|
||||
expect(startInFreeTabDetailed).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
vi.clearAllMocks();
|
||||
list([row("s-a", 1), row("s-b", 2)]);
|
||||
vi.mocked(startInFreeTabDetailed).mockRejectedValueOnce(new Error("boom"));
|
||||
renderIt();
|
||||
await user.click(await screen.findByRole("button", { name: /resume 2/i }));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-b"]));
|
||||
expect(screen.getByRole("checkbox", { name: /ws-1/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
@@ -24,6 +24,12 @@ import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||
// Local mode unless a test says otherwise.
|
||||
const perms = vi.hoisted(() => ({ local: true }));
|
||||
vi.mock("../../AuthGate", async (importOriginal) => {
|
||||
const m = await importOriginal<typeof import("../../AuthGate")>();
|
||||
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
||||
});
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
default: (p: {
|
||||
value: string;
|
||||
@@ -75,6 +81,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitTree: vi.fn(),
|
||||
gitTreeAll: vi.fn(),
|
||||
gitBlame: vi.fn(),
|
||||
gitDownloadUrl: (id: number, path: string) =>
|
||||
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
|
||||
gitUpload: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -143,6 +152,7 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = true;
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -307,16 +317,29 @@ describe("LaneChangesPanel", () => {
|
||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
||||
});
|
||||
|
||||
it("says so, quietly, when the lane's directory is not a git repo", async () => {
|
||||
it("says so, quietly, beside the tree at full width, when the folder is not a repo", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
expect(
|
||||
await screen.findByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens on Files, its only tab, when the workspace folder is not a repo", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(
|
||||
await screen.findByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("lane-changes-browse")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Files" })).toHaveAttribute("aria-pressed", "true");
|
||||
for (const name of ["Changes", "Review", "History"])
|
||||
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
|
||||
@@ -545,6 +568,94 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a folder again on every open, so files added since show (not a stale empty level)", async () => {
|
||||
let filled = false;
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
entries:
|
||||
path === "src"
|
||||
? filled
|
||||
? [{ name: "app.ts", path: "src/app.ts", dir: false }]
|
||||
: []
|
||||
: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("src"));
|
||||
await waitFor(() => expect(api.lanes.gitTree).toHaveBeenCalledWith(7, "src"));
|
||||
expect(within(sidebar).queryByText("app.ts")).toBeNull();
|
||||
// Source arrives from outside the panel (the File Explorer, a terminal).
|
||||
filled = true;
|
||||
await user.click(within(sidebar).getByText("src")); // close
|
||||
await user.click(within(sidebar).getByText("src")); // open again → read again
|
||||
expect(await within(sidebar).findByText("app.ts")).toBeInTheDocument();
|
||||
// And the header's File Explorer reports a change: open folders re-read.
|
||||
filled = false;
|
||||
rerender(<LaneChangesPanel laneId={7} full filesChanged={1} />);
|
||||
await waitFor(() => expect(within(sidebar).queryByText("app.ts")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
entries:
|
||||
path === "out"
|
||||
? [{ name: "report.md", path: "out/report.md", dir: false }]
|
||||
: [{ name: "out", path: "out", dir: true }],
|
||||
truncated: false,
|
||||
}));
|
||||
vi.mocked(api.lanes.gitUpload).mockResolvedValue({ path: "out/data.csv" });
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
const input = within(sidebar).getByTestId("lane-changes-upload-input") as HTMLInputElement;
|
||||
const picked = vi.spyOn(input, "click").mockImplementation(() => {});
|
||||
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
|
||||
await user.click(screen.getByTestId("lane-changes-menu-upload"));
|
||||
expect(picked).toHaveBeenCalled();
|
||||
const file = new File(["a,b\n"], "data.csv", { type: "text/csv" });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
await waitFor(() => expect(api.lanes.gitUpload).toHaveBeenCalledWith(7, "out", file));
|
||||
// The folder it landed in is re-read and opened.
|
||||
expect(await within(sidebar).findByText("report.md")).toBeInTheDocument();
|
||||
|
||||
const clicked: string[] = [];
|
||||
const anchor = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
||||
this: HTMLAnchorElement
|
||||
) {
|
||||
clicked.push(this.getAttribute("href") ?? "");
|
||||
});
|
||||
await user.pointer({ keys: "[MouseRight]", target: within(sidebar).getByText("report.md") });
|
||||
await user.click(screen.getByTestId("lane-changes-menu-download"));
|
||||
expect(clicked).toEqual(["/api/lanes/7/git/download?path=out%2Freport.md"]);
|
||||
anchor.mockRestore();
|
||||
picked.mockRestore();
|
||||
});
|
||||
|
||||
it("offers no upload, download or File Explorer in local mode", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
entries:
|
||||
path === "out"
|
||||
? [{ name: "report.md", path: "out/report.md", dir: false }]
|
||||
: [{ name: "out", path: "out", dir: true }],
|
||||
truncated: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
|
||||
expect(screen.getByTestId("lane-changes-tree-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-changes-menu-upload")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-changes-menu-explorer")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
@@ -1687,6 +1798,24 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a file from Files when the workspace folder is not a repo", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<Tabbed laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-browse");
|
||||
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(
|
||||
screen.queryByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { LaneGitFacts } from "../../../lib/types";
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn(), git: vi.fn() } },
|
||||
}));
|
||||
|
||||
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
||||
@@ -164,6 +164,29 @@ describe("LaneGitBar", () => {
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says there is nothing to push instead of asking to push zero commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 0 }));
|
||||
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /^Push$/ }));
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-reads stale facts: a commit made since still gets the push confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 1 }));
|
||||
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /^Push$/ }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Push 1 commit") })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
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 { LaneSourcesView, RunHandle } from "../../../lib/api";
|
||||
|
||||
@@ -241,6 +241,27 @@ describe("WorkspaceHome", () => {
|
||||
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 () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import { MAX_SLOTS } from "../../lib/paneLayout";
|
||||
import { MAX_SLOTS, slotOfKey } from "../../lib/paneLayout";
|
||||
|
||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||
export function paneRunId(laneId: number, slot: number): string {
|
||||
@@ -58,7 +58,9 @@ export async function startInFreeTabDetailed(
|
||||
/** The first pane slot with no live run, or null when all ten are busy. */
|
||||
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
runs
|
||||
.filter((r) => r.laneId === laneId && r.status === "running")
|
||||
.map((r) => slotOfKey(r.slot ?? 0))
|
||||
);
|
||||
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
|
||||
return null;
|
||||
|
||||
@@ -171,8 +171,16 @@ export function OnboardingTour() {
|
||||
style = { left: right + 16, top };
|
||||
} else if (rect.bottom + 180 < window.innerHeight) {
|
||||
style = { left, top: rect.bottom + 12 };
|
||||
} else if (rect.top > 200) {
|
||||
style = { left, top: rect.top - 12, transform: "translateY(-100%)" };
|
||||
} 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%)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,18 +6,26 @@
|
||||
* on the right — Claude's Uploads dialog). Content comes only through routes
|
||||
* that already guard it: a file inside the workspace through
|
||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
||||
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
|
||||
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
|
||||
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
|
||||
* an attached image or text file through `GET /api/run/drop/:name`, an image
|
||||
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
|
||||
* text file it read, wrote or named (a generated report) anywhere else through
|
||||
* `GET /api/sessions/:id/file`.
|
||||
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||
* or the project's); a built-in skill has no file and shows by name only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
@@ -31,11 +39,17 @@ export interface PreviewScope {
|
||||
}
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||
// An attached file the drop route serves as text (`DROP_TEXT_TYPES` in server/routes/run.js).
|
||||
const DROP_TEXT_RE = /\.(txt|md|markdown|csv|tsv|log|json|xml|ya?ml)$/i;
|
||||
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
|
||||
const DROP_RE =
|
||||
/\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
/[\\/](?:agenthub-drops-\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
/** A dropped text file shown at most this long. */
|
||||
const DROP_TEXT_MAX = 200_000;
|
||||
|
||||
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||
/** `C:\a\b` → `C:/a/b`: a Windows path matched like a POSIX one. */
|
||||
const slashed = (p: string) => p.replace(/\\/g, "/");
|
||||
export const baseName = (p: string) => p.split(/[\\/]/).pop() || p;
|
||||
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||
export const extOf = (p: string) => {
|
||||
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
|
||||
@@ -54,37 +68,62 @@ type Content =
|
||||
function useContent(path: string, scope: PreviewScope): Content {
|
||||
const [content, setContent] = useState<Content>({ kind: "loading" });
|
||||
useEffect(() => {
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
setContent(
|
||||
IMAGE_RE.test(path)
|
||||
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
|
||||
: { kind: "none" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
const show = (p: Promise<Content>) => {
|
||||
setContent({ kind: "loading" });
|
||||
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
|
||||
};
|
||||
const base = scope.cwd.replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
|
||||
// Claude Code's own files (memory): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
|
||||
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
|
||||
else if (DROP_TEXT_RE.test(path))
|
||||
show(
|
||||
fetch(src).then(async (r) => {
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const text = await r.text();
|
||||
return {
|
||||
kind: "text",
|
||||
text: text.slice(0, DROP_TEXT_MAX),
|
||||
truncated: text.length > DROP_TEXT_MAX,
|
||||
};
|
||||
})
|
||||
);
|
||||
else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
|
||||
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
||||
show(
|
||||
api.ccConfig
|
||||
.file(path)
|
||||
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
||||
);
|
||||
} else if (IMAGE_RE.test(file) && scope.sessionId) {
|
||||
// An image it read elsewhere (a screenshot under ~/.cache): served
|
||||
// only because this session's own Read named it.
|
||||
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
|
||||
} else if (scope.sessionId && /^(?:\/|[A-Za-z]:\/)/.test(file)) {
|
||||
// A text file it read or wrote elsewhere (a report under ~/Documents):
|
||||
// served only because this session's own Read/Write/Edit named it.
|
||||
show(
|
||||
api.sessions
|
||||
.readFile(scope.sessionId, path)
|
||||
.then((f) => ({ kind: "text", text: f.text, truncated: f.truncated }))
|
||||
);
|
||||
} else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const rel = path.slice(base.length + 1);
|
||||
const rel = file.slice(base.length + 1);
|
||||
const target =
|
||||
base === scope.laneCwd.replace(/\/+$/, "")
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
if (IMAGE_RE.test(path)) {
|
||||
@@ -109,7 +148,11 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
const { t } = useTranslation("run");
|
||||
const content = useContent(path, scope);
|
||||
const loaded = useContent(path, scope);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const content: Content =
|
||||
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
||||
if (content.kind === "loading") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
@@ -123,6 +166,7 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
<img
|
||||
src={content.src}
|
||||
alt={baseName(path)}
|
||||
onError={() => setBroken(content.src)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
@@ -206,13 +250,19 @@ export function FilePreview({
|
||||
export function FileListModal({
|
||||
title,
|
||||
paths,
|
||||
label = displayName,
|
||||
scope,
|
||||
onClose,
|
||||
onDragItem,
|
||||
}: {
|
||||
title: string;
|
||||
paths: string[];
|
||||
/** A row's text; the file name by default. */
|
||||
label?: (path: string) => string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
/** Rows drag into the prompt box; the dialog then closes, out of the way. */
|
||||
onDragItem?: (e: DragEvent, path: string) => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState(paths[0] ?? "");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -241,12 +291,18 @@ export function FileListModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(p)}
|
||||
draggable={!!onDragItem}
|
||||
onDragStart={(e) => {
|
||||
onDragItem?.(e, p);
|
||||
// Not in dragstart itself: removing the source there cancels the drag.
|
||||
if (onDragItem) window.setTimeout(onClose, 0);
|
||||
}}
|
||||
title={p}
|
||||
aria-current={p === picked}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 truncate">{displayName(p)}</span>
|
||||
<span className="min-w-0 truncate">{label(p)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -258,3 +314,58 @@ export function FileListModal({
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** The session's skills in the list dialog, each one's SKILL.md previewed. */
|
||||
export function SkillListModal({
|
||||
title,
|
||||
names,
|
||||
scope,
|
||||
onClose,
|
||||
draggable = false,
|
||||
}: {
|
||||
title: string;
|
||||
names: string[];
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
/** Rows drag into the prompt box as `/name`. */
|
||||
draggable?: boolean;
|
||||
}) {
|
||||
const [files, setFiles] = useState<Map<string, string> | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void Promise.all([
|
||||
api.ccConfig.skills(undefined, scope.cwd).catch(() => ({ items: [] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).then(([sk, pl]) => {
|
||||
const found = new Map<string, string>();
|
||||
const add = (name: string, file: string | undefined) => {
|
||||
if (file && !found.has(name)) found.set(name, file);
|
||||
};
|
||||
for (const p of pl.plugins) {
|
||||
const items = p.contributes?.items;
|
||||
for (const it of [...(items?.skills ?? []), ...(items?.commands ?? [])])
|
||||
add(`${p.name}:${it.name}`, it.file);
|
||||
}
|
||||
// A personal skill wins over a project one of the same name, as in Claude Code.
|
||||
for (const s of sk.items) if (s.scope === "user") add(s.name, s.file);
|
||||
for (const s of sk.items) add(s.name, s.file);
|
||||
if (live) setFiles(found);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [scope.cwd]);
|
||||
if (!files) return null;
|
||||
// A built-in skill has no file: its name stands in and previews as nothing.
|
||||
const byPath = new Map(names.map((n) => [files.get(n) ?? n, n]));
|
||||
return (
|
||||
<FileListModal
|
||||
title={title}
|
||||
paths={[...byPath.keys()]}
|
||||
label={(p) => byPath.get(p) ?? displayName(p)}
|
||||
scope={scope}
|
||||
onClose={onClose}
|
||||
onDragItem={draggable ? (e, p) => dragSkill(e, byPath.get(p) ?? p) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
/**
|
||||
* @file ChatOutline.tsx
|
||||
* @description The right-hand panel of a Chat pane shown alone, 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
|
||||
* 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
|
||||
* this session" — the files attached to its prompts, the files it read and the
|
||||
* memory files it used (a click opens the list with a preview), its skills
|
||||
* and MCP servers (a click opens the Claude config page). Read from
|
||||
* memory files it used and its skills (a click opens the list with a
|
||||
* preview — a skill's SKILL.md), and its MCP servers (a click opens the
|
||||
* Claude config page). Every row but MCP drags into the prompt box: a file
|
||||
* as an attachment, a group as all of its files, a lone skill as `/name`.
|
||||
* Read from
|
||||
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
|
||||
* uses for the collapsed button's count.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type DragEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
@@ -30,7 +42,10 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type SessionOutline } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import { isShown } from "../../hooks/useOnScreen";
|
||||
import { displayName, extOf } from "./ChatFilePreview";
|
||||
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const EMPTY: SessionOutline = {
|
||||
@@ -43,29 +58,48 @@ const EMPTY: SessionOutline = {
|
||||
mcp: [],
|
||||
};
|
||||
|
||||
/** The session's outline, re-read on a visibility-gated poll. */
|
||||
export function useSessionOutline(sessionId: string): SessionOutline {
|
||||
/** The session's outline, re-read on a visibility-gated poll — skipped too
|
||||
* while `onScreen` says the pane is hidden (another workspace or tab, kept
|
||||
* mounted). An unchanged answer keeps the old object: no re-render. */
|
||||
export function useSessionOutline(
|
||||
sessionId: string,
|
||||
onScreen?: RefObject<boolean>
|
||||
): SessionOutline {
|
||||
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.outline(sessionId)
|
||||
.then((o) => live && setOutline({ ...EMPTY, ...o }))
|
||||
.then((o) => live && setOutline(keepIfSame<SessionOutline>({ ...EMPTY, ...o })))
|
||||
.catch(() => {});
|
||||
void load();
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
if (onScreen ? isShown(onScreen) : document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, onScreen]);
|
||||
return outline;
|
||||
}
|
||||
|
||||
export type OutlineList = "uploads" | "files" | "memory";
|
||||
export type OutlineList = "uploads" | "files" | "memory" | "skills";
|
||||
|
||||
/** A listed file as a prompt attachment: an upload by its bare path, as the
|
||||
* composer attaches one; anything else as an `@` mention. */
|
||||
export function outlineAttachment(path: string, upload = false): Attachment {
|
||||
return { path: upload ? path : `@${path}`, name: displayName(path) };
|
||||
}
|
||||
|
||||
const dragFiles =
|
||||
(paths: string[], upload = false) =>
|
||||
(e: DragEvent) =>
|
||||
dragAttachments(
|
||||
e,
|
||||
paths.map((p) => outlineAttachment(p, upload))
|
||||
);
|
||||
|
||||
function fileIcon(path: string) {
|
||||
const ext = extOf(path).toLowerCase();
|
||||
@@ -88,7 +122,8 @@ function Section({
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
/** Beside the title: a count, or a "3/7". */
|
||||
count?: number | string;
|
||||
action?: ReactNode;
|
||||
/** Takes the panel's free height and scrolls inside it. */
|
||||
grow?: boolean;
|
||||
@@ -136,12 +171,15 @@ function UsedRow({
|
||||
label,
|
||||
names,
|
||||
onClick,
|
||||
onDragStart,
|
||||
to,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
names: string[];
|
||||
onClick?: () => void;
|
||||
/** The row drags into the prompt box. */
|
||||
onDragStart?: (e: DragEvent) => void;
|
||||
to?: string;
|
||||
}) {
|
||||
const shown = names.slice(0, 2).join(", ");
|
||||
@@ -161,7 +199,14 @@ function UsedRow({
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" title={title} onClick={onClick} className={rowClass}>
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
draggable={!!onDragStart}
|
||||
onDragStart={onDragStart}
|
||||
className={rowClass}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
@@ -181,6 +226,8 @@ export function ChatOutline({
|
||||
onOpenList: (list: OutlineList) => void;
|
||||
}) {
|
||||
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 close = (
|
||||
<button
|
||||
@@ -198,21 +245,47 @@ export function ChatOutline({
|
||||
<aside
|
||||
data-testid="chat-outline"
|
||||
aria-label={t("chat.outline.panel")}
|
||||
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
|
||||
// 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.
|
||||
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 && (
|
||||
<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) => {
|
||||
const done = task.status === "completed";
|
||||
const now = task.status === "in_progress";
|
||||
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 ? (
|
||||
<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" />
|
||||
</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
|
||||
@@ -241,6 +314,8 @@ export function ChatOutline({
|
||||
title={p}
|
||||
aria-label={`${extOf(p)}: ${stem(p)}`}
|
||||
onClick={() => onOpenFile(p)}
|
||||
draggable
|
||||
onDragStart={dragFiles([p])}
|
||||
className={rowClass}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
@@ -261,6 +336,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.uploads")}
|
||||
names={o.uploads.map(displayName)}
|
||||
onClick={() => onOpenList("uploads")}
|
||||
onDragStart={dragFiles(o.uploads, true)}
|
||||
/>
|
||||
)}
|
||||
{o.files.length > 0 && (
|
||||
@@ -269,6 +345,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.files")}
|
||||
names={o.files.map(displayName)}
|
||||
onClick={() => onOpenList("files")}
|
||||
onDragStart={dragFiles(o.files)}
|
||||
/>
|
||||
)}
|
||||
{o.memory.length > 0 && (
|
||||
@@ -277,6 +354,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.memory")}
|
||||
names={o.memory.map(stem)}
|
||||
onClick={() => onOpenList("memory")}
|
||||
onDragStart={dragFiles(o.memory)}
|
||||
/>
|
||||
)}
|
||||
{o.skills.length > 0 && (
|
||||
@@ -284,7 +362,9 @@ export function ChatOutline({
|
||||
icon={BookOpen}
|
||||
label={t("chat.skills")}
|
||||
names={o.skills}
|
||||
to="/settings/agents/claude-code?tab=skills"
|
||||
onClick={() => onOpenList("skills")}
|
||||
// One command per prompt: several are dragged one by one from the list.
|
||||
onDragStart={o.skills.length === 1 ? (e) => dragSkill(e, o.skills[0]!) : undefined}
|
||||
/>
|
||||
)}
|
||||
{o.mcp.length > 0 && (
|
||||
@@ -292,7 +372,7 @@ export function ChatOutline({
|
||||
icon={Plug}
|
||||
label={t("chat.outline.connectors")}
|
||||
names={o.mcp}
|
||||
to="/settings/agents/claude-code?tab=mcp"
|
||||
to={href("agents/claude-code?tab=mcp")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
* prompt it started on, else its tool and number; a combo tab shows every
|
||||
* pane's icon and name. A tab carries a state dot (waiting for you / error /
|
||||
* running — a combo shows the most urgent, with screen-reader text). A tab's
|
||||
* X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
|
||||
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
|
||||
* X stops every session in it AND archives them (`endRuns`,
|
||||
* `DELETE /api/run/:id?archive=1`); the moon beside it (and the tab menu's
|
||||
* Sleep) stops its AI sessions but keeps them listed, to resume later. Either
|
||||
* asks first when one is an AI tool, since it may be mid-turn; a shell closes
|
||||
* at once. Arrow
|
||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
@@ -25,8 +28,15 @@
|
||||
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
||||
* of another tab, a tab (or a pane header) joins that tab instead
|
||||
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`).
|
||||
* one, up to four panes). A pane drag held over another tab for `SPRING_MS` shows that tab
|
||||
* (spring-loaded, as a file manager's folder), so the pane can be dropped on
|
||||
* any of its panes' edges; near either end of an overflowing bar a drag
|
||||
* scrolls it. A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||
* and `workspaceOf` names each tab's workspace before its own name. Settings
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -41,18 +51,26 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { House, Moon, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { canSleep, endRuns } from "../../lib/stopSession";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot, startInFreeTab } from "../lanes/startSession";
|
||||
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
||||
import {
|
||||
PANE_DRAG_TYPE,
|
||||
RunIcon,
|
||||
paneDragSlot,
|
||||
setDragChip,
|
||||
startPaneDrag,
|
||||
type GroupsUpdate,
|
||||
} from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import {
|
||||
type PaneGroups,
|
||||
@@ -62,6 +80,7 @@ import {
|
||||
joinGroup,
|
||||
leaves,
|
||||
reorderGroups,
|
||||
slotOfKey,
|
||||
} from "../../lib/paneLayout";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
@@ -77,7 +96,7 @@ export function runName(
|
||||
): string {
|
||||
if (r.tool === "shell") return terminal;
|
||||
const prompt = r.promptPreview?.trim().split("\n")[0];
|
||||
return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`;
|
||||
return session?.name || prompt || `${toolLabel} #${slotOfKey(r.slot ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
export type RunState = "awaiting" | "error" | "running";
|
||||
@@ -149,6 +168,14 @@ interface Tab {
|
||||
|
||||
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
||||
|
||||
/** How long a pane drag rests on a tab before that tab is shown. */
|
||||
export const SPRING_MS = 600;
|
||||
/** Within this many px of an overflowing bar's end, a drag scrolls it. */
|
||||
const EDGE_SCROLL_PX = 40;
|
||||
|
||||
/** The Settings tab's key among the tabs' (a group's key is its slots). */
|
||||
const SETTINGS_KEY = "settings";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
tabs: runs,
|
||||
@@ -160,10 +187,14 @@ export function ConsoleTabs({
|
||||
actions,
|
||||
sessions,
|
||||
onRenamed,
|
||||
workspaceOf,
|
||||
settings,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
tabs: RunHandle[];
|
||||
/** Tabs from every workspace: the name of the workspace a run belongs to. */
|
||||
workspaceOf?: (r: RunHandle) => string;
|
||||
/** The tabs: groups of panes, the active one on screen. */
|
||||
groups: PaneGroups;
|
||||
/** Reorders the tabs or takes a pane out into a tab of its own. */
|
||||
@@ -181,9 +212,12 @@ export function ConsoleTabs({
|
||||
onHome?: () => void;
|
||||
/** Right end of the bar (the single pane's split menus). */
|
||||
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 navigate = useNavigate();
|
||||
const settingsHref = useSettingsHref();
|
||||
const aiTools = useAiTools();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// The tab being renamed (its run id) and the draft name.
|
||||
@@ -240,8 +274,59 @@ export function ConsoleTabs({
|
||||
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
|
||||
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
|
||||
};
|
||||
// A pane drag resting on a tab (its drawn index) shows it after SPRING_MS.
|
||||
const spring = useRef<{ at: number; timer: ReturnType<typeof setTimeout> } | null>(null);
|
||||
const stopSpring = () => {
|
||||
if (spring.current) clearTimeout(spring.current.timer);
|
||||
spring.current = null;
|
||||
};
|
||||
useEffect(() => stopSpring, []);
|
||||
const hoveredIndexAt = (x: number) => {
|
||||
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
|
||||
return els.findIndex((el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return x >= b.left && x <= b.right;
|
||||
});
|
||||
};
|
||||
const springAt = (e: DragEvent) => {
|
||||
const slot = paneDragSlot();
|
||||
const i = hoveredIndexAt(e.clientX);
|
||||
const tab = tabs[i];
|
||||
// Only a pane drag, onto a tab not on screen that does not hold it.
|
||||
const target =
|
||||
slot !== null &&
|
||||
tab &&
|
||||
!settings?.active &&
|
||||
tab.index !== groups.active &&
|
||||
!tab.slots.includes(slot)
|
||||
? i
|
||||
: null;
|
||||
if ((spring.current?.at ?? null) === target) return;
|
||||
stopSpring();
|
||||
if (target === null || !tab) return;
|
||||
const index = tab.index;
|
||||
spring.current = {
|
||||
at: target,
|
||||
timer: setTimeout(() => {
|
||||
spring.current = null;
|
||||
if (paneDragSlot() === null) return;
|
||||
onGroupsChange((g) =>
|
||||
g.active === index || !g.groups[index] ? g : { ...g, active: index }
|
||||
);
|
||||
}, SPRING_MS),
|
||||
};
|
||||
};
|
||||
// An overflowing bar has no scrollbar: near either end, a drag scrolls it.
|
||||
const edgeScroll = (x: number) => {
|
||||
const list = listRef.current;
|
||||
if (!list || list.scrollWidth <= list.clientWidth) return;
|
||||
const b = list.getBoundingClientRect();
|
||||
if (x < b.left + EDGE_SCROLL_PX) list.scrollLeft -= 16;
|
||||
else if (x > b.right - EDGE_SCROLL_PX) list.scrollLeft += 16;
|
||||
};
|
||||
const endDrag = () => {
|
||||
dragged.current = [];
|
||||
stopSpring();
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
};
|
||||
@@ -250,6 +335,8 @@ export function ConsoleTabs({
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
edgeScroll(e.clientX);
|
||||
springAt(e);
|
||||
const join = joinIndexAt(e.clientX);
|
||||
const at = join === null ? dropIndexAt(e.clientX) : null;
|
||||
setJoinAt((d) => (d === join ? d : join));
|
||||
@@ -257,6 +344,7 @@ export function ConsoleTabs({
|
||||
},
|
||||
onDragLeave: (e: DragEvent) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||
stopSpring();
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
},
|
||||
@@ -325,7 +413,10 @@ export function ConsoleTabs({
|
||||
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
|
||||
const toolLabel = (r: RunHandle) =>
|
||||
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
|
||||
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||
const label = (r: RunHandle) => {
|
||||
const name = runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||
return workspaceOf ? `${workspaceOf(r)} · ${name}` : name;
|
||||
};
|
||||
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
|
||||
// A combo's dot is its most urgent AI pane's; a shell has none.
|
||||
const tabState = (tab: Tab): RunState | null => {
|
||||
@@ -344,30 +435,10 @@ export function ConsoleTabs({
|
||||
}
|
||||
};
|
||||
|
||||
// An AI session may be mid-turn: stopping a tab holding one asks first. A
|
||||
// shell holds no agent.
|
||||
const close = async (tab: Tab) => {
|
||||
if (
|
||||
tab.runs.some((r) => r.tool !== "shell") &&
|
||||
!(await confirmDialog({
|
||||
title:
|
||||
tab.runs.length > 1
|
||||
? t("tabs.stopGroupTitle", { count: tab.runs.length })
|
||||
: t("tabs.stopTitle", { name: label(tab.focus) }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
for (const r of tab.runs) {
|
||||
try {
|
||||
await api.run.kill(r.id);
|
||||
onClosed(r.id);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
// Stop archives the tab's sessions; Sleep keeps them listed to resume
|
||||
// (`endRuns` asks first for an AI pane, which may be mid-turn).
|
||||
const close = async (tab: Tab, sleep = false) => {
|
||||
for (const id of await endRuns(tab.runs, { sleep, name: label(tab.focus), t })) onClosed(id);
|
||||
};
|
||||
|
||||
const startRename = (r: RunHandle) => {
|
||||
@@ -388,8 +459,9 @@ export function ConsoleTabs({
|
||||
};
|
||||
|
||||
// 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 last = tabs.length - 1;
|
||||
const last = keys.length - 1;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? i === last
|
||||
@@ -404,11 +476,14 @@ export function ConsoleTabs({
|
||||
: e.key === "End"
|
||||
? last
|
||||
: null;
|
||||
if (to === null || !tabs[to]) return;
|
||||
const key = to === null ? undefined : keys[to];
|
||||
if (key === undefined) return;
|
||||
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.
|
||||
const pickTab = (fn: (tab: Tab) => unknown) => () => {
|
||||
@@ -447,7 +522,7 @@ export function ConsoleTabs({
|
||||
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
|
||||
>
|
||||
{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 state = tabState(tab);
|
||||
if (single && renaming === single.id) {
|
||||
@@ -481,10 +556,11 @@ export function ConsoleTabs({
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragged.current = tab.slots;
|
||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||
if (single) startPaneDrag(e, single.slot ?? 0, label(single));
|
||||
else {
|
||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
setDragChip(e, tabLabel(tab));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => tabCtx.open(e, tab)}
|
||||
@@ -545,6 +621,20 @@ export function ConsoleTabs({
|
||||
<span className="truncate">{tabLabel(tab)}</span>
|
||||
{state && <RunDot state={state} />}
|
||||
</button>
|
||||
{tab.runs.some(canSleep) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`console-tab-sleep-${tab.key}`}
|
||||
onClick={() => void close(tab, true)}
|
||||
aria-label={t("tabs.sleep")}
|
||||
title={t("tabs.sleep")}
|
||||
className={`shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
|
||||
active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`console-tab-close-${tab.key}`}
|
||||
@@ -560,6 +650,48 @@ export function ConsoleTabs({
|
||||
</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 ref={menuRef} className="relative flex items-center px-1.5">
|
||||
<button
|
||||
@@ -609,7 +741,10 @@ export function ConsoleTabs({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${item} !text-fg-muted`}
|
||||
onClick={() => navigate("/settings/agents")}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
navigate(settingsHref("agents"));
|
||||
}}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("tabs.agentSettings")}
|
||||
@@ -647,6 +782,14 @@ export function ConsoleTabs({
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{ctxTab.runs.some(canSleep) && (
|
||||
<MenuItem
|
||||
testId="console-tab-menu-sleep"
|
||||
onSelect={pickTab((tab) => void close(tab, true))}
|
||||
>
|
||||
{t("tabs.sleep")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Plus } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -51,8 +51,9 @@ export interface LaneConsolePaneProps {
|
||||
onRunExit?: () => void;
|
||||
/** The terminal takes the keyboard when it appears (default). */
|
||||
autoFocus?: boolean;
|
||||
/** The only pane shown: its Chat view gets the right panel. */
|
||||
solo?: boolean;
|
||||
/** The pane spans the grid's full width or height: its Chat view's right
|
||||
* panel starts open (collapsed in a quarter-sized pane). */
|
||||
panelOpen?: boolean;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||
@@ -71,7 +72,7 @@ export function LaneConsolePane({
|
||||
onNewSession,
|
||||
onRunExit,
|
||||
autoFocus,
|
||||
solo = false,
|
||||
panelOpen = false,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||
@@ -88,6 +89,13 @@ export function LaneConsolePane({
|
||||
: null;
|
||||
const chat = useSessionChat(chatSession);
|
||||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||||
// Stable, so the memoized terminal is not re-rendered for a new closure.
|
||||
const onRunExitRef = useRef(onRunExit);
|
||||
onRunExitRef.current = onRunExit;
|
||||
const onTerminalExit = useCallback(() => {
|
||||
setHandle(null);
|
||||
onRunExitRef.current?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
@@ -201,7 +209,7 @@ export function LaneConsolePane({
|
||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||
laneId={laneId}
|
||||
laneCwd={currentLane?.cwd || ""}
|
||||
outline={solo}
|
||||
panelOpen={panelOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -209,13 +217,11 @@ export function LaneConsolePane({
|
||||
runId={handle.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={autoFocus && !(chatable && chat)}
|
||||
covered={!!(chatable && chat)}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// the tab bar moves on (another tab, or the home's prompt box)
|
||||
// rather than leaving a dead terminal the user has to click out of.
|
||||
onExit={() => {
|
||||
setHandle(null);
|
||||
onRunExit?.();
|
||||
}}
|
||||
onExit={onTerminalExit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,37 +12,78 @@
|
||||
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
||||
* 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
|
||||
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
||||
* Shown alone (`outline`), it also has Claude Desktop's activity panel
|
||||
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
|
||||
* file button with the output count until clicked (remembered per browser);
|
||||
* a file clicked there opens its preview in the panel's place
|
||||
* 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,
|
||||
* outputs, what the session used), open at first in a pane spanning the
|
||||
* grid's full width or height (`panelOpen`) once the session has an output,
|
||||
* 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
|
||||
* 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
|
||||
* clicked there opens its preview
|
||||
* in the panel's place
|
||||
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
|
||||
* the workspace's folder —, a group
|
||||
* of files a list with a preview (`FileListModal`). A file in a turn's
|
||||
* of files or the used skills a list with a preview (`FileListModal`,
|
||||
* `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
|
||||
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||
* 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
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
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 { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { ChatAddMenu } from "./ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
|
||||
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
||||
import { resolveIn } from "../../lib/resolvePath";
|
||||
import { Select } from "../Select";
|
||||
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||
import { Divider, showTerminal } from "./PaneGrid";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
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";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
@@ -53,7 +94,69 @@ const MODES: [PermissionMode, string][] = [
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
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 f = Number(localStorage.getItem(SIDE_KEY));
|
||||
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. */
|
||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||
@@ -103,13 +206,118 @@ 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,
|
||||
sessionId,
|
||||
cwd,
|
||||
laneId = null,
|
||||
laneCwd = "",
|
||||
outline = false,
|
||||
panelOpen = false,
|
||||
}: {
|
||||
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||
sessionId: string;
|
||||
@@ -117,41 +325,81 @@ export function PaneChat({
|
||||
/** The workspace and its folder: a file under `cwd` is previewed through it. */
|
||||
laneId?: number | null;
|
||||
laneCwd?: string;
|
||||
/** The pane is shown alone: room for the right panel. */
|
||||
outline?: boolean;
|
||||
/** The right panel starts open: the pane spans the grid's width or height. */
|
||||
panelOpen?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
||||
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
// Collapsed until asked for, like Claude Desktop's activity panel.
|
||||
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
|
||||
// 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.
|
||||
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) => {
|
||||
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
|
||||
setOutlineOpen(open);
|
||||
sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
|
||||
setOutlineChoice(open);
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
// The side panel's width as a fraction of the pane: the dragged one, else
|
||||
// a default per content (a preview wants more room than the panel).
|
||||
const [dragged, setDragged] = useState(storedSide);
|
||||
const side = dragged ?? (preview ? 0.45 : 0.3);
|
||||
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 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 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 [waiting, setWaiting] = useState(false);
|
||||
// Stop presses, for the conversation's live line (issue #74).
|
||||
const [stops, setStops] = useState(0);
|
||||
const readWorking = useCallback(
|
||||
() =>
|
||||
api.agents
|
||||
.list({ session_id: sessionId, status: "working" })
|
||||
// With session_id the server answers every agent of the session, its
|
||||
// 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(() => {}),
|
||||
[sessionId]
|
||||
);
|
||||
@@ -163,22 +411,104 @@ export function PaneChat({
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working ? 5000 : undefined,
|
||||
intervalMs: working || waiting ? 5000 : undefined,
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(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(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
const path = resolveIn(cwd, named);
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
setPreview(path);
|
||||
},
|
||||
[laneId, cwd]
|
||||
);
|
||||
|
||||
// Before the session starts the CLI may be asking in the terminal (trust
|
||||
// this folder? with "No, exit" preselected): an Enter from here answers it.
|
||||
// Asked, not refused: whether the session row appears right after the
|
||||
// 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.
|
||||
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);
|
||||
if (!ok) toast.error(t("chat.notSent"));
|
||||
return ok;
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
const send = async () => {
|
||||
if (!text || uploading) return;
|
||||
if (!command(text)) return;
|
||||
if (!(await command(text))) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
@@ -186,29 +516,34 @@ export function PaneChat({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-prompt-drop
|
||||
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||
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 className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div ref={areaRef} className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
live
|
||||
follow={follow}
|
||||
onOpenFile={(path) => {
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview, when it has room for one.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
if (outline) setPreview(path);
|
||||
}}
|
||||
waiting={asking}
|
||||
stopped={stops}
|
||||
onOpenTerminal={openTerminal}
|
||||
onContext={onContext}
|
||||
onNotStarted={setNotStarted}
|
||||
onOpenFile={openFile}
|
||||
/>
|
||||
{outline && !outlineOpen && !preview && (
|
||||
{!outlineOpen && !preview && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-outline-show"
|
||||
onClick={() => openOutline(true)}
|
||||
title={t("chat.outline.show")}
|
||||
aria-label={t("chat.outline.show")}
|
||||
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
|
||||
className="absolute right-3 top-2 flex h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{activity.outputs.length}
|
||||
@@ -217,7 +552,24 @@ export function PaneChat({
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<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
|
||||
bare
|
||||
images={images}
|
||||
@@ -226,7 +578,7 @@ export function PaneChat({
|
||||
pickFiles={pickFiles}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
onSubmit={() => void send()}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={claude}
|
||||
@@ -244,16 +596,17 @@ export function PaneChat({
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ContextMeter tokens={context.tokens} model={context.model} />
|
||||
{claude && (
|
||||
<ModelMenu
|
||||
value={model}
|
||||
onChange={(v) => {
|
||||
if (command(`/model ${v || "default"}`)) setModel(v);
|
||||
onChange={async (v) => {
|
||||
if (await command(`/model ${v || "default"}`)) setModel(v);
|
||||
}}
|
||||
tool={run.tool}
|
||||
effort={effort}
|
||||
onEffortChange={(e) => {
|
||||
if (command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||
onEffortChange={async (e) => {
|
||||
if (await command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -263,8 +616,10 @@ export function PaneChat({
|
||||
data-testid="pane-chat-stop"
|
||||
onClick={() => {
|
||||
// An interrupted turn fires no Stop hook: clear it here.
|
||||
if (interruptRun(runId)) setWorking(false);
|
||||
else toast.error(t("chat.notStopped"));
|
||||
if (interruptRun(runId)) {
|
||||
setWorking(false);
|
||||
setStops((n) => n + 1);
|
||||
} else toast.error(t("chat.notStopped"));
|
||||
}}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
@@ -276,7 +631,7 @@ export function PaneChat({
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
onClick={() => void send()}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
@@ -291,33 +646,59 @@ export function PaneChat({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{outline &&
|
||||
(preview ? (
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when there is room, over it when not.
|
||||
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
|
||||
{preview ? (
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when the pane has room, over it when not.
|
||||
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{list && (
|
||||
)
|
||||
)}
|
||||
{(preview || outlineOpen) && (
|
||||
// Only beside the chat: a panel over a narrow pane has no edge to drag.
|
||||
<div className="hidden [@container(min-width:40rem)]:contents">
|
||||
<Divider
|
||||
gridRef={rootRef}
|
||||
dir="row"
|
||||
at={1 - side}
|
||||
box={{ x: 0, y: 0, w: 1, h: 1 }}
|
||||
label={t("chat.outline.resize")}
|
||||
onRatio={(r) => {
|
||||
const f = clampSide(1 - r);
|
||||
localStorage.setItem(SIDE_KEY, String(f));
|
||||
setDragged(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{list === "skills" && (
|
||||
<SkillListModal
|
||||
title={t("chat.outline.skills")}
|
||||
names={activity.skills}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
draggable={claude}
|
||||
/>
|
||||
)}
|
||||
{list && list !== "skills" && (
|
||||
<FileListModal
|
||||
title={t(`chat.outline.${list}`)}
|
||||
paths={activity[list]}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
onDragItem={(e, p) => dragAttachments(e, [outlineAttachment(p, list === "uploads")])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,15 +10,26 @@
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or take the pane OUT of this tab (its session keeps running
|
||||
* in a tab of its own — so its icon is not the tab's stop X), or stop its
|
||||
* session there (X, `onStop`) without taking it out first. The grid shows
|
||||
* session there (X, `onStop`, which archives it) or put an AI session to
|
||||
* sleep (the moon left of it, `onSleep`: stopped but kept to resume) without
|
||||
* taking it out first. The grid shows
|
||||
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
||||
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
||||
* shows its state dot, as its tab does.
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). While a
|
||||
* drag is on, the pane it carries is dimmed, the browser's ghost is a small
|
||||
* chip with its name (`setDragChip`) instead of a whole header, and the drop
|
||||
* preview names what the drop will do (`dropAction`). The dragged pane's
|
||||
* header stays mounted even once the tab bar has switched tabs under the
|
||||
* drag (`ConsoleTabs` spring-loads a hovered tab) — removing the drag's
|
||||
* source node would lose its `dragend`. A
|
||||
* right-click on a header offers the swap, a split with a new terminal and
|
||||
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
|
||||
* moves only the line and commits the ratio on release, so the layout is
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -31,15 +42,18 @@ import {
|
||||
GripVertical,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
Moon,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { canSleep } from "../../lib/stopSession";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
EMPTY_GROUPS,
|
||||
type Edge,
|
||||
type GroupsScope,
|
||||
type PaneGroups,
|
||||
type PaneLayout,
|
||||
type Rect,
|
||||
@@ -57,16 +71,39 @@ import {
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptWithImages } from "./RunSetup";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
let dragSlot: number | null = null;
|
||||
|
||||
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
|
||||
export function startPaneDrag(e: React.DragEvent, slot: number): void {
|
||||
/** The slot a pane drag carries, or null (no pane drag is on). */
|
||||
export const paneDragSlot = (): number | null => dragSlot;
|
||||
|
||||
/** The drag's ghost: a small chip naming what moves, not a snapshot of a
|
||||
* whole pane header or tab. Drawn off screen for the browser to copy. */
|
||||
export function setDragChip(e: React.DragEvent, label: string): void {
|
||||
const dt = e.dataTransfer;
|
||||
if (!label || typeof dt?.setDragImage !== "function") return;
|
||||
const chip = document.createElement("div");
|
||||
chip.textContent = label;
|
||||
chip.className =
|
||||
"fixed -top-96 left-0 max-w-[16rem] truncate rounded-md border border-accent/60 bg-surface-2 px-2.5 py-1 text-xs font-medium text-fg-primary shadow-lg";
|
||||
document.body.appendChild(chip);
|
||||
dt.setDragImage(chip, 12, 12);
|
||||
// The browser copies it during dragstart; it may go right after.
|
||||
window.setTimeout(() => chip.remove(), 0);
|
||||
}
|
||||
|
||||
/** Starts dragging a pane or a tab (`slot`) onto the grid; `label` names it
|
||||
* on the drag's chip. */
|
||||
export function startPaneDrag(e: React.DragEvent, slot: number, label?: string): void {
|
||||
dragSlot = slot;
|
||||
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
if (label) setDragChip(e, label);
|
||||
}
|
||||
|
||||
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
|
||||
@@ -82,10 +119,10 @@ export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
|
||||
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
|
||||
|
||||
/** A workspace's tabs (groups of split panes), loaded and saved per
|
||||
* workspace. An update that lands after a workspace switch never writes into
|
||||
* the new one. */
|
||||
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
|
||||
const load = (id: number | null) => ({
|
||||
* workspace (or once for every workspace, `all`). An update that lands after
|
||||
* a workspace switch never writes into the new one. */
|
||||
export function usePaneGroups(laneId: GroupsScope | null): [PaneGroups, GroupsUpdate] {
|
||||
const load = (id: GroupsScope | null) => ({
|
||||
laneId: id,
|
||||
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
|
||||
});
|
||||
@@ -133,6 +170,14 @@ export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone
|
||||
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
|
||||
}
|
||||
|
||||
/** What a drop does, as an i18n key (`lanes` namespace): the center swaps a
|
||||
* pane already shown, or shows one from another tab in the target's place
|
||||
* (the target gets a tab of its own); an edge places it on that side. */
|
||||
export function dropAction(l: PaneLayout, src: number, zone: Zone): string {
|
||||
if (zone !== "center") return `panes.drop.${zone}`;
|
||||
return leaves(l.root).includes(src) ? "panes.drop.swap" : "panes.drop.replace";
|
||||
}
|
||||
|
||||
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
||||
* an outside click or Escape. */
|
||||
function PaneMenu({
|
||||
@@ -194,7 +239,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";
|
||||
|
||||
/** 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 }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
@@ -208,7 +264,14 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
aria-pressed={chat}
|
||||
title={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"
|
||||
>
|
||||
{chat ? (
|
||||
@@ -343,10 +406,19 @@ export function PaneGrid({
|
||||
onNewSplit,
|
||||
stateOf,
|
||||
onStop,
|
||||
onSleep,
|
||||
keyOf = (slot) => slot,
|
||||
kept = [],
|
||||
}: {
|
||||
layout: PaneLayout;
|
||||
/** Stops the session in `slot` (the header's X). */
|
||||
/** 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 (and archives) the session in `slot` (the header's X). */
|
||||
onStop?: (slot: number) => void;
|
||||
/** Sleeps the AI session in `slot`: stopped, kept to resume (the moon). */
|
||||
onSleep?: (slot: number) => void;
|
||||
/** A live AI run's state for its header dot (none for a shell). */
|
||||
stateOf?: (r: RunHandle) => RunState | null;
|
||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||
@@ -367,6 +439,8 @@ export function PaneGrid({
|
||||
const { t } = useTranslation("lanes");
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
|
||||
// The slot a pane drag carries (a header here, or a tab from the bar).
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
const { panes, dividers } = layoutRects(layout.root);
|
||||
const shown = leaves(layout.root);
|
||||
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
||||
@@ -385,6 +459,7 @@ export function PaneGrid({
|
||||
const end = () => {
|
||||
dragSlot = null;
|
||||
setDrop(null);
|
||||
setDragging(null);
|
||||
};
|
||||
window.addEventListener("dragend", end);
|
||||
window.addEventListener("drop", end);
|
||||
@@ -448,6 +523,7 @@ export function PaneGrid({
|
||||
if (!isPaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
if (dragSlot !== dragging) setDragging(dragSlot);
|
||||
const z = zoneAt(e);
|
||||
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
|
||||
setDrop((d) =>
|
||||
@@ -464,32 +540,52 @@ export function PaneGrid({
|
||||
const z = zoneAt(e);
|
||||
dragSlot = null;
|
||||
setDrop(null);
|
||||
setDragging(null);
|
||||
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
||||
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
||||
}}
|
||||
>
|
||||
{mounted.map((slot) => {
|
||||
{[
|
||||
...mounted.map((slot) => ({ key: keyOf(slot), slot, node: null as ReactNode })),
|
||||
...kept.map((k) => ({ key: k.key, slot: null, node: k.node })),
|
||||
].map(({ key, slot, node }) => {
|
||||
if (slot === null) {
|
||||
// The same element shape as a hidden pane below, so React keeps it.
|
||||
return (
|
||||
<div key={key} data-testid={`pane-kept-${key}`} className="hidden">
|
||||
{false}
|
||||
<div className="flex min-h-0 flex-1 flex-col">{node}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const r = rectOf.get(slot);
|
||||
const run = runOf(slot);
|
||||
const focused = layout.focus === slot;
|
||||
const moving = dragging === slot;
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
key={key}
|
||||
data-testid={`pane-${slot}`}
|
||||
data-focused={r && focused ? "true" : undefined}
|
||||
// A menu opened in this pane stays inside it (`menuRoom`).
|
||||
data-menu-bounds
|
||||
className={r ? "absolute flex flex-col" : "hidden"}
|
||||
data-dragging={moving ? "true" : undefined}
|
||||
className={r ? `absolute flex flex-col${moving ? " opacity-40" : ""}` : "hidden"}
|
||||
style={r ? pct(r) : undefined}
|
||||
onPointerDownCapture={() => r && focus(slot)}
|
||||
// Tabbing into a pane focuses it as a click does.
|
||||
onFocusCapture={() => r && focus(slot)}
|
||||
>
|
||||
{multi && r && (
|
||||
{/* The dragged pane's header stays while the drag lasts, even
|
||||
hidden in another tab: it is the drag's source node. */}
|
||||
{((multi && r) || moving) && (
|
||||
<div
|
||||
data-testid={`pane-header-${slot}`}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, slot)}
|
||||
onDragStart={(e) => {
|
||||
startPaneDrag(e, slot, run ? label(run) : t("panes.empty"));
|
||||
setDragging(slot);
|
||||
}}
|
||||
onContextMenu={(e) => paneCtx.open(e, slot)}
|
||||
title={t("panes.drag")}
|
||||
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
|
||||
@@ -552,6 +648,18 @@ export function PaneGrid({
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{run && onSleep && canSleep(run) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-sleep-${slot}`}
|
||||
title={t("panes.sleep")}
|
||||
aria-label={t("panes.sleep")}
|
||||
onClick={() => onSleep(slot)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{run && onStop && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -607,6 +715,11 @@ export function PaneGrid({
|
||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||
{t("panes.closeShort")}
|
||||
</MenuItem>
|
||||
{onSleep && canSleep(runOf(paneCtx.menu.item)) && (
|
||||
<MenuItem testId="pane-menu-sleep" onSelect={pickPane(onSleep)}>
|
||||
{t("panes.sleep")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onStop && runOf(paneCtx.menu.item) && (
|
||||
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
||||
{t("panes.stop")}
|
||||
@@ -624,12 +737,19 @@ export function PaneGrid({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{preview && (
|
||||
{preview && drop && (
|
||||
<div
|
||||
data-testid="pane-drop-preview"
|
||||
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||
className="pointer-events-none absolute z-20 flex items-center justify-center rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||
style={pct(preview)}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
data-testid="pane-drop-label"
|
||||
className="max-w-[90%] truncate rounded-md bg-accent-solid px-2.5 py-1 text-xs font-medium text-white shadow-lg"
|
||||
>
|
||||
{t(dropAction(layout, drop.src, drop.zone))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
||||
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
|
||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||
* remembered for the + menu's Recent uploads), and a Files panel row
|
||||
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* 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;
|
||||
* 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;
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
@@ -60,10 +63,14 @@ import { Select, menuRoom } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
droppedAttachment,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
|
||||
@@ -221,6 +228,15 @@ export function PromptEditor({
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
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
|
||||
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||
useLayoutEffect(() => {
|
||||
@@ -229,6 +245,10 @@ export function PromptEditor({
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [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 [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
@@ -388,6 +408,58 @@ export function PromptEditor({
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
||||
|
||||
// A drop lands anywhere on the screen around the composer — the nearest
|
||||
// `data-prompt-drop` ancestor (a Chat pane, a replay, the workspace home),
|
||||
// else a `bare` editor's parent, the box every caller draws around it and
|
||||
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
|
||||
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
|
||||
// `/command`, a file is uploaded, a local `file://` list an `@path` chip
|
||||
// and any other text is inserted.
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropRef = useRef<(e: DragEvent) => void>(() => {});
|
||||
dropRef.current = (e) => {
|
||||
const atts = droppedAttachments(e.dataTransfer);
|
||||
const skill = droppedSkill(e.dataTransfer);
|
||||
if (atts.length) {
|
||||
e.preventDefault();
|
||||
onImagesChange((prev) => addAttachments(prev, atts));
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
else {
|
||||
// Only text (a file manager's or an editor's `file://` list, a
|
||||
// selection, a link): what the terminal beneath would paste.
|
||||
// On the textarea itself the browser inserts text at the drop point.
|
||||
const dropped = droppedText(e.dataTransfer);
|
||||
if (!dropped || ("text" in dropped && e.target === taRef.current)) return;
|
||||
e.preventDefault();
|
||||
if ("attachments" in dropped)
|
||||
onImagesChange((prev) => addAttachments(prev, dropped.attachments));
|
||||
else insert(dropped.text);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const zone =
|
||||
rootRef.current?.closest<HTMLElement>("[data-prompt-drop]") ??
|
||||
(bare ? rootRef.current?.parentElement : rootRef.current);
|
||||
if (!zone) return;
|
||||
const over = (e: DragEvent) => {
|
||||
const types = e.dataTransfer?.types ?? [];
|
||||
const accepts = [ATTACH_MIME, SKILL_MIME, "Files", "text/uri-list", "text/plain"];
|
||||
if (!accepts.some((m) => types.includes(m))) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = "copy";
|
||||
};
|
||||
const drop = (e: DragEvent) => dropRef.current(e);
|
||||
zone.addEventListener("dragover", over);
|
||||
zone.addEventListener("drop", drop);
|
||||
return () => {
|
||||
zone.removeEventListener("dragover", over);
|
||||
zone.removeEventListener("drop", drop);
|
||||
};
|
||||
}, [bare]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
@@ -431,7 +503,7 @@ export function PromptEditor({
|
||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={rootRef} className="relative">
|
||||
{images.length > 0 && (
|
||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||
{images.map((img) => (
|
||||
@@ -450,7 +522,7 @@ export function PromptEditor({
|
||||
) : (
|
||||
<span
|
||||
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" />
|
||||
<span className="truncate">{img.name}</span>
|
||||
@@ -503,52 +575,64 @@ export function PromptEditor({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// 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" })]);
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
onScroll={(e) => {
|
||||
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// 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" })]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// A Files panel row is not a file: say a drop is welcome.
|
||||
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const att = droppedAttachment(e.dataTransfer);
|
||||
if (att) {
|
||||
e.preventDefault();
|
||||
onImagesChange((prev) => addAttachments(prev, [att]));
|
||||
return;
|
||||
}
|
||||
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={
|
||||
bare
|
||||
? "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"
|
||||
}
|
||||
/>
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
|
||||
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
|
||||
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
|
||||
/>
|
||||
{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 && (
|
||||
<p
|
||||
role={upload === "failed" ? "alert" : "status"}
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* (`loadXterm`), not bundled with the Workspace chunk.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`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
|
||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||
* a file. The pane also registers with
|
||||
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -25,11 +25,15 @@
|
||||
* 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
|
||||
* (`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
|
||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
||||
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
|
||||
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
|
||||
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
||||
* READ the user's clipboard) — over plain HTTP too, through `writeClipboard`'s
|
||||
* textarea fallback. While the socket is down a "Reconnecting…"
|
||||
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
|
||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||
@@ -37,7 +41,16 @@
|
||||
* here takes the lead back (`resize` with `drive: true`).
|
||||
* @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 type { Terminal } from "@xterm/xterm";
|
||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||
@@ -48,10 +61,13 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -73,9 +89,11 @@ const clipboardProvider: IClipboardProvider = {
|
||||
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
|
||||
// clipboard. An empty write (a "clear") is ignored.
|
||||
writeText: (_selection, text) => {
|
||||
// Silent and best effort: no toast per copy, and on a non-secure origin
|
||||
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
|
||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
// Silent and best effort: no toast per copy. On a non-secure origin
|
||||
// (`http://<lan-ip>`, no Clipboard API) writeClipboard falls back to the
|
||||
// textarea copy, which the browser allows while the drag that made the
|
||||
// selection still counts as the user's gesture.
|
||||
if (text) void writeClipboard(text);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -122,6 +140,10 @@ export function loadXterm(): Promise<Xterm> {
|
||||
const RETRY_MIN_MS = 1500;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
/** A text drag (a Files/Changes row, a skill, selected text): xterm.js has no
|
||||
* drop handling of its own, so the pane pastes it, as a local terminal does. */
|
||||
const hasText = (e: DragEvent) =>
|
||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
@@ -133,9 +155,22 @@ interface TerminalViewProps {
|
||||
/** Take the keyboard when it appears (default). Off for a split pane that
|
||||
* is not the focused one, or keys would land in whichever mounted last. */
|
||||
autoFocus?: boolean;
|
||||
/** Another layer (the pane's chat) covers it. xterm stops drawing only when
|
||||
* its IntersectionObserver says off screen, and a covered terminal is not —
|
||||
* so it is moved out of the viewport with a transform, which keeps its
|
||||
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||
covered?: boolean;
|
||||
}
|
||||
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: TerminalViewProps) {
|
||||
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
|
||||
* a terminal only cares about its own props (its owner keeps `onExit` stable). */
|
||||
export const TerminalView = memo(function TerminalView({
|
||||
runId,
|
||||
wsBaseUrl,
|
||||
onExit,
|
||||
autoFocus = true,
|
||||
covered = false,
|
||||
}: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
// every render, and as an effect dep it would tear the terminal down and
|
||||
@@ -184,11 +219,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||
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();
|
||||
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;
|
||||
setDrop("uploading");
|
||||
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,
|
||||
// and why it only showed up once content scrolled.
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontSize: readTerminalFontSize(),
|
||||
cursorBlink: true,
|
||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
|
||||
@@ -289,9 +355,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
||||
// because that protocol reports button presses only, never the wheel — so
|
||||
// xterm takes the emulation path for it as well.
|
||||
term.attachCustomWheelEventHandler(() => {
|
||||
// psmux (Windows) never turns tracking on, so xterm never reports the
|
||||
// wheel there; on the server's `{type:"wheel"}` this pane sends the SGR
|
||||
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
||||
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
||||
let ownWheel = false;
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
const mode = term.modes.mouseTrackingMode;
|
||||
const tracked = mode !== "none" && mode !== "x10";
|
||||
if (!tracked && ownWheel) {
|
||||
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return tracked || term.buffer.active.type !== "alternate";
|
||||
});
|
||||
|
||||
@@ -327,6 +404,29 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
requestAnimationFrame(() => findInput.current?.focus());
|
||||
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;
|
||||
}
|
||||
// Ctrl+Z: a terminal sends ^Z (SIGTSTP), which SUSPENDS claude or
|
||||
// codex — the pane drops to its shell and the session looks closed,
|
||||
// with nothing on screen saying `fg` brings it back. In a browser
|
||||
// Ctrl+Z means undo, so it sends Ctrl+_ — undo in Claude Code's prompt
|
||||
// and in bash/readline — and never suspends the program.
|
||||
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "z") {
|
||||
ev.preventDefault();
|
||||
term.input("\x1f", true);
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
@@ -441,6 +541,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
requestAnimationFrame(scaleToFit);
|
||||
} else if (msg.type === "lead") {
|
||||
takeLead();
|
||||
} else if (msg.type === "wheel") {
|
||||
ownWheel = true;
|
||||
} else if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||
@@ -486,6 +588,12 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const reassert = takeLead;
|
||||
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 = () => {
|
||||
// Never fit a collapsed console — see sendSize.
|
||||
const el = containerRef.current;
|
||||
@@ -506,6 +614,17 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
// Settings → Appearance changed the terminal's font or size: an open
|
||||
// pane takes it now, re-measured and re-fitted like a font landing.
|
||||
const onFontPref = () => {
|
||||
const size = readTerminalFontSize();
|
||||
const family = TERMINAL_FONTS[readTerminalFont()];
|
||||
if (term.options.fontSize === size && term.options.fontFamily?.trimEnd() === family) return;
|
||||
term.options.fontSize = size;
|
||||
term.options.fontFamily = family;
|
||||
refit();
|
||||
};
|
||||
window.addEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
|
||||
return () => {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
@@ -513,7 +632,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
detachIme();
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
@@ -562,6 +683,35 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||
(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]
|
||||
@@ -601,25 +751,31 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
setDrop(hasFiles(e) ? "hint" : "paste");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" || d === "paste" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
onDrop={onDrop}
|
||||
onPasteCapture={onPaste}
|
||||
onFocus={() => notePaneFocus(runId)}
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={covered ? { transform: "translateX(-300vw)" } : undefined}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
<p id={hintId} className="sr-only">
|
||||
{t("terminal.keyboardHint")}
|
||||
</p>
|
||||
@@ -720,4 +876,4 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
* @file ChatFilePreview.test.tsx
|
||||
* @description Where a Chat pane's file preview reads from: a file under the
|
||||
* pane's folder through the lane's git file read, a memory file under
|
||||
* `~/.claude` through the config pages' read, anything else by path only; and
|
||||
* the list dialog previewing the file picked in it.
|
||||
* `~/.claude` through the config pages' read, a dropped text file through the
|
||||
* drop route, an image read outside the folder through the session's image
|
||||
* route, a text file read or written outside it through the session's file route, a Windows path like a POSIX one, anything else by path only; the
|
||||
* list dialog previewing the file picked in it; and the skills dialog finding
|
||||
* each skill's SKILL.md by name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
|
||||
import { FileListModal, FilePreview, SkillListModal, displayName } from "../ChatFilePreview";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -28,7 +31,37 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
sessions: {
|
||||
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
||||
readFile: vi.fn((_id: string, p: string) =>
|
||||
p.endsWith(".md")
|
||||
? Promise.resolve({ text: "# Audit report", truncated: false })
|
||||
: Promise.reject(new Error("404"))
|
||||
),
|
||||
},
|
||||
ccConfig: {
|
||||
skills: vi.fn().mockResolvedValue({
|
||||
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
|
||||
}),
|
||||
plugins: vi.fn().mockResolvedValue({
|
||||
plugins: [
|
||||
{
|
||||
name: "agenthub",
|
||||
contributes: {
|
||||
items: {
|
||||
skills: [
|
||||
{
|
||||
name: "file-headers",
|
||||
description: null,
|
||||
file: "/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md",
|
||||
},
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
file: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
||||
@@ -53,10 +86,48 @@ describe("FilePreview", () => {
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", () => {
|
||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||
it("reads Windows paths the same way", async () => {
|
||||
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
cleanup();
|
||||
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||
render(<FilePreview path={tool} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Launch notes")).toBeTruthy();
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(tool);
|
||||
expect(screen.getByText("b")).toBeTruthy(); // the name, not the whole path
|
||||
});
|
||||
|
||||
it("reads a dropped text file through the drop route", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("a long paste"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const drop = "C:\\Users\\k\\.claude\\agenthub\\drops\\0123456789abcdef-pasted-text.txt";
|
||||
render(<FilePreview path={drop} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("a long paste")).toBeTruthy();
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/run/drop/0123456789abcdef-pasted-text.txt");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows an image it read outside the folder through the session, its path if refused", () => {
|
||||
render(<FilePreview path="/h/.cache/shot.png" scope={scope} onClose={() => {}} />);
|
||||
const img = screen.getByAltText("shot.png") as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("/img/s1?path=/h/.cache/shot.png");
|
||||
fireEvent.error(img);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reads a text file it wrote outside the folder through the session", async () => {
|
||||
const report = "/home/a/Documents/audit/REPORT_ssh_ec2.md";
|
||||
render(<FilePreview path={report} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Audit report")).toBeTruthy();
|
||||
expect(vi.mocked(api.sessions.readFile)).toHaveBeenCalledWith("s1", report);
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", async () => {
|
||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileListModal", () => {
|
||||
@@ -76,3 +147,28 @@ describe("FileListModal", () => {
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillListModal", () => {
|
||||
it("lists the skills by name and previews each one's SKILL.md", async () => {
|
||||
render(
|
||||
<SkillListModal
|
||||
title="Skills"
|
||||
names={["agenthub:file-headers", "xlsx", "simplify"]}
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
// A plugin's skill first, read through the config read.
|
||||
expect(await screen.findByRole("button", { name: "agenthub:file-headers" })).toBeTruthy();
|
||||
await screen.findByText("Launch notes");
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(
|
||||
"/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md"
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "xlsx" }));
|
||||
await screen.findByText("Launch notes");
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith("/h/.claude/skills/xlsx/SKILL.md");
|
||||
// A built-in skill has no file.
|
||||
fireEvent.click(screen.getByRole("button", { name: "simplify" }));
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description The Chat pane's activity panel renders a session's outline the
|
||||
* way Claude Desktop does: progress with done/current items, outputs by name
|
||||
* with an EXT tag (a click opens that file), and "Used in this session" rows —
|
||||
* uploads, files read and memory open their list, skills link to the config.
|
||||
* uploads, files read, memory and skills open their list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -42,6 +42,10 @@ describe("ChatOutline", () => {
|
||||
);
|
||||
expect(screen.getByText("Parse plan").className).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" }));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
||||
@@ -52,15 +56,47 @@ describe("ChatOutline", () => {
|
||||
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
|
||||
fireEvent.click(screen.getByTitle("Memory: release"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("memory");
|
||||
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
|
||||
"/settings/agents/claude-code?tab=skills"
|
||||
);
|
||||
fireEvent.click(screen.getByTitle("Skills: xlsx"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("skills");
|
||||
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drags its rows into the prompt box", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={outline}
|
||||
onClose={() => {}}
|
||||
onOpenFile={() => {}}
|
||||
onOpenList={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const dragged = (el: HTMLElement) => {
|
||||
const data: Record<string, string> = {};
|
||||
fireEvent.dragStart(el, {
|
||||
dataTransfer: { setData: (k: string, v: string) => (data[k] = v) },
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const attached = (el: HTMLElement) =>
|
||||
JSON.parse(dragged(el)["application/x-agenthub-attach"] ?? "[]") as { path: string }[];
|
||||
expect(attached(screen.getByRole("button", { name: "XLSX: Progress report" }))).toEqual([
|
||||
{ path: "@/w/out/Progress report.xlsx", name: "Progress report.xlsx" },
|
||||
]);
|
||||
expect(attached(screen.getByTitle(/a\.ts, /)).map((a) => a.path)).toEqual([
|
||||
"@/w/src/a.ts",
|
||||
"@/w/src/b.ts",
|
||||
"@/w/src/c.ts",
|
||||
]);
|
||||
// An upload goes as the composer attaches one: its bare path.
|
||||
expect(attached(screen.getByTitle("Uploads: minutes.xlsx"))[0]!.path).toBe(outline.uploads[0]);
|
||||
expect(dragged(screen.getByTitle("Skills: xlsx"))["application/x-agenthub-skill"]).toBe("xlsx");
|
||||
});
|
||||
|
||||
it("hides progress and the used section when there is none", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @file ConsoleTabs.test.tsx
|
||||
* @description The tab bar's spring-loaded tabs: a pane drag resting on
|
||||
* another tab shows it after `SPRING_MS`, so the pane can be dropped on that
|
||||
* tab's panes; moving off before then, or resting on the tab already holding
|
||||
* the pane, shows nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { pane, split, type PaneGroups } from "../../../lib/paneLayout";
|
||||
import { ConsoleTabs, SPRING_MS } from "../ConsoleTabs";
|
||||
import { PANE_DRAG_TYPE, paneDragSlot } from "../PaneGrid";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
|
||||
const run = (slot: number): RunHandle => ({
|
||||
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
slot,
|
||||
status: "running",
|
||||
cwd: "/w",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
tool: "shell",
|
||||
});
|
||||
// Tab 0: pane 0 alone (on screen); tab 1: panes 1 and 2.
|
||||
const GROUPS: PaneGroups = {
|
||||
groups: [
|
||||
{ root: pane(0), focus: 0 },
|
||||
{ root: split("row", pane(1), pane(2)), focus: 1 },
|
||||
],
|
||||
active: 0,
|
||||
};
|
||||
|
||||
function setup() {
|
||||
let groups = GROUPS;
|
||||
const onGroupsChange = vi.fn((fn: (g: PaneGroups) => PaneGroups) => {
|
||||
groups = fn(groups);
|
||||
});
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ConsoleTabs
|
||||
lane={LANE}
|
||||
tabs={[run(0), run(1), run(2)]}
|
||||
groups={GROUPS}
|
||||
onGroupsChange={onGroupsChange}
|
||||
onOpen={vi.fn()}
|
||||
onClosed={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
// jsdom lays nothing out: tab i spans x 100i … 100i+100.
|
||||
for (const el of document.querySelectorAll<HTMLElement>("[data-tab-index]")) {
|
||||
const i = Number(el.dataset.tabIndex);
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: i * 100,
|
||||
right: i * 100 + 100,
|
||||
width: 100,
|
||||
top: 0,
|
||||
bottom: 40,
|
||||
height: 40,
|
||||
}) as DOMRect;
|
||||
}
|
||||
return { onGroupsChange, groups: () => groups };
|
||||
}
|
||||
|
||||
const dataTransfer = () => ({
|
||||
types: [PANE_DRAG_TYPE],
|
||||
setData: vi.fn(),
|
||||
getData: () => "",
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
});
|
||||
|
||||
function drag(type: "dragStart" | "dragOver", el: Element, clientX = 0) {
|
||||
const ev = createEvent[type](el, { dataTransfer: dataTransfer() });
|
||||
Object.defineProperty(ev, "clientX", { value: clientX });
|
||||
fireEvent(el, ev);
|
||||
}
|
||||
|
||||
const tabEl = (key: string) => screen.getByTestId(`console-tab-${key}`).parentElement as Element;
|
||||
|
||||
describe("ConsoleTabs spring-loaded tabs", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("a pane drag resting on another tab shows that tab", () => {
|
||||
const { onGroupsChange, groups } = setup();
|
||||
drag("dragStart", tabEl("0"));
|
||||
expect(paneDragSlot()).toBe(0);
|
||||
drag("dragOver", tabEl("1-2"), 150);
|
||||
act(() => void vi.advanceTimersByTime(SPRING_MS - 1));
|
||||
expect(onGroupsChange).not.toHaveBeenCalled();
|
||||
act(() => void vi.advanceTimersByTime(1));
|
||||
expect(groups().active).toBe(1);
|
||||
});
|
||||
|
||||
it("moving off the tab first, or resting on the pane's own tab, shows nothing", () => {
|
||||
const { onGroupsChange } = setup();
|
||||
drag("dragStart", tabEl("0"));
|
||||
drag("dragOver", tabEl("1-2"), 150);
|
||||
act(() => void vi.advanceTimersByTime(SPRING_MS / 2));
|
||||
drag("dragOver", tabEl("0"), 50);
|
||||
act(() => void vi.advanceTimersByTime(SPRING_MS * 2));
|
||||
expect(onGroupsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,30 +7,52 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneChat } from "../PaneChat";
|
||||
import { PaneChat, contextWindow } from "../PaneChat";
|
||||
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";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// 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">
|
||||
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.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>
|
||||
),
|
||||
}));
|
||||
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||
vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: 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) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -65,6 +87,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
skills: [],
|
||||
mcp: [],
|
||||
}),
|
||||
readFile: r({ text: "", truncated: false }),
|
||||
},
|
||||
lanes: {
|
||||
gitFile: r({
|
||||
@@ -122,7 +145,12 @@ function renderChat(run: Partial<typeof RUN> = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.mocked(submitToRun).mockClear());
|
||||
beforeEach(() => {
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
||||
localStorage.removeItem("agenthub.chat.draft.s1");
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("PaneChat — the + menu", () => {
|
||||
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
||||
@@ -196,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", () => {
|
||||
it("types /model into the pane", async () => {
|
||||
renderChat();
|
||||
@@ -230,10 +296,9 @@ const byId = (id: string) => () => screen.getByTestId(id);
|
||||
|
||||
describe("PaneChat — the activity panel", () => {
|
||||
it("opens from the file button, previews a file, and closes back to the panel", async () => {
|
||||
localStorage.removeItem("agenthub.chat.outlineOpen");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
@@ -248,7 +313,71 @@ describe("PaneChat — the activity panel", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
|
||||
});
|
||||
|
||||
it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => {
|
||||
const pane = (panelOpen: boolean) => (
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const { rerender } = render(pane(true));
|
||||
await screen.findByTestId("chat-outline");
|
||||
rerender(pane(false));
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
||||
});
|
||||
|
||||
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");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const splitter = await screen.findByRole("separator", { name: "Resize side panel" });
|
||||
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
||||
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
||||
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
||||
expect(splitter.getAttribute("aria-valuenow")).toBe("65");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,7 +388,7 @@ describe("PaneChat — an edited file", () => {
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("edited-in"));
|
||||
@@ -272,3 +401,153 @@ describe("PaneChat — an edited file", () => {
|
||||
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — the unsent prompt", () => {
|
||||
it("keeps the half-typed prompt and its attachments when the chat unmounts (terminal view, reload)", async () => {
|
||||
const first = renderChat();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
||||
target: { value: "half typed" },
|
||||
});
|
||||
// A dropped attachment, as a Files row or an "Edited N files" row brings it.
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: {
|
||||
types: ["application/x-agenthub-attach"],
|
||||
getData: (t: string) =>
|
||||
t === "application/x-agenthub-attach"
|
||||
? JSON.stringify({ path: "@src/a.ts", name: "a.ts" })
|
||||
: "",
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
screen.getByPlaceholderText("Write a message…").dispatchEvent(drop);
|
||||
});
|
||||
expect(await screen.findByText("a.ts")).toBeInTheDocument();
|
||||
first.unmount();
|
||||
|
||||
renderChat();
|
||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||
});
|
||||
|
||||
// A drag from outside the dashboard (a file manager, VS Code, a selection)
|
||||
// carries only text: the terminal beneath pastes it, so the chat takes it too.
|
||||
const textDrop = (data: Record<string, string>) => {
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: { types: Object.keys(data), getData: (t: string) => data[t] ?? "", files: [] },
|
||||
});
|
||||
act(() => {
|
||||
screen.getByTestId("pane-chat").dispatchEvent(drop);
|
||||
});
|
||||
return drop;
|
||||
};
|
||||
|
||||
it("attaches the local files of a dropped file:// list", async () => {
|
||||
renderChat();
|
||||
const drop = textDrop({
|
||||
"text/uri-list": "file:///home/u/My%20Docs/spec.md\r\nfile:///home/u/b.ts",
|
||||
"text/plain": "/home/u/My Docs/spec.md",
|
||||
});
|
||||
expect(drop.defaultPrevented).toBe(true);
|
||||
expect(await screen.findByText("spec.md")).toBeInTheDocument();
|
||||
expect(screen.getByText("b.ts")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
||||
target: { value: "read" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith(
|
||||
"agenthub-lane-1",
|
||||
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts dropped plain text into the prompt", () => {
|
||||
renderChat();
|
||||
const drop = textDrop({ "text/plain": "src/lib/x.ts" });
|
||||
expect(drop.defaultPrevented).toBe(true);
|
||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("src/lib/x.ts");
|
||||
});
|
||||
|
||||
// Issue #73: before the session starts the CLI may be asking in the terminal
|
||||
// (trust this folder? — "No, exit" preselected); an Enter sent from here
|
||||
// would answer it, so a send asks first.
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* @file PaneGrid.test.ts
|
||||
* @description The pane drop preview: it draws where the dragged pane will
|
||||
* actually land (`dropPreview` over `applyDrop`), not half of the target's
|
||||
* current box, and nothing for a drop that changes nothing.
|
||||
* current box, and nothing for a drop that changes nothing; and the label
|
||||
* naming what a drop does (`dropAction`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dropPreview } from "../PaneGrid";
|
||||
import { dropAction, dropPreview } from "../PaneGrid";
|
||||
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
|
||||
|
||||
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
@@ -34,3 +35,15 @@ describe("dropPreview", () => {
|
||||
expect(dropPreview(full, 5, 0, "left")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropAction", () => {
|
||||
it("an edge places the pane on that side", () => {
|
||||
expect(dropAction(AB, 0, "left")).toBe("panes.drop.left");
|
||||
expect(dropAction(AB, 2, "bottom")).toBe("panes.drop.bottom");
|
||||
});
|
||||
|
||||
it("the center swaps a shown pane, or shows one from another tab in its place", () => {
|
||||
expect(dropAction(AB, 0, "center")).toBe("panes.drop.swap");
|
||||
expect(dropAction(AB, 2, "center")).toBe("panes.drop.replace");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* 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`
|
||||
* 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
|
||||
* 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
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { 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 i18n from "i18next";
|
||||
import {
|
||||
@@ -28,7 +28,13 @@ import {
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
dragAttachments,
|
||||
dragProjectPath,
|
||||
dragSkill,
|
||||
recentUploads,
|
||||
} from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -111,6 +117,31 @@ function renderEditor(value: string, slash = true) {
|
||||
const box = () => screen.getByPlaceholderText("Ask anything…");
|
||||
|
||||
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 () => {
|
||||
const spies = renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
@@ -348,6 +379,134 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
||||
});
|
||||
|
||||
it("takes a drop anywhere on a bare editor's box; an extra folder's row comes absolute", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<PromptEditor
|
||||
bare
|
||||
slash={false}
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<div data-testid="toolbar" />
|
||||
</div>
|
||||
<section data-drag-root="/Users/tester/extra/">
|
||||
<span data-testid="row" />
|
||||
</section>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const data: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (data[k] = v),
|
||||
getData: (k: string) => data[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(data);
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
dragProjectPath(
|
||||
{ dataTransfer, currentTarget: screen.getByTestId("row") } as unknown as React.DragEvent,
|
||||
"notes.md"
|
||||
);
|
||||
expect(data["text/plain"]).toBe("/Users/tester/extra/notes.md");
|
||||
const over = createEvent.dragOver(screen.getByTestId("toolbar"), { dataTransfer });
|
||||
fireEvent(screen.getByTestId("toolbar"), over);
|
||||
expect(over.defaultPrevented).toBe(true);
|
||||
fireEvent.drop(screen.getByTestId("toolbar"), { dataTransfer });
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @/Users/tester/extra/notes.md");
|
||||
});
|
||||
|
||||
it("takes a drop anywhere inside its data-prompt-drop screen, not only on the box", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<div data-prompt-drop>
|
||||
<div data-testid="transcript" />
|
||||
<PromptEditor
|
||||
value="see"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("see", images)}</output>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const dataTransfer = {
|
||||
getData: (k: string) =>
|
||||
k === ATTACH_MIME ? JSON.stringify([{ path: "@/w/a.ts", name: "a.ts" }]) : "",
|
||||
types: [ATTACH_MIME],
|
||||
files: [],
|
||||
};
|
||||
const over = createEvent.dragOver(screen.getByTestId("transcript"), { dataTransfer });
|
||||
fireEvent(screen.getByTestId("transcript"), over);
|
||||
expect(over.defaultPrevented).toBe(true);
|
||||
fireEvent.drop(screen.getByTestId("transcript"), { dataTransfer });
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see @/w/a.ts");
|
||||
});
|
||||
|
||||
it("takes an activity panel's drag: a group of files as chips, a skill as the /command", () => {
|
||||
function Harness() {
|
||||
const [value, setValue] = useState("check it");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
slash={false}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages(value, images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const drag = (start: (e: React.DragEvent) => void) => {
|
||||
const data: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (data[k] = v),
|
||||
getData: (k: string) => data[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(data);
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
start({ dataTransfer } as unknown as React.DragEvent);
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
};
|
||||
drag((e) =>
|
||||
dragAttachments(e, [
|
||||
{ path: "@/a/x.md", name: "x.md" },
|
||||
{ path: "/d/drops/0123456789abcdef.png", name: "y.png" },
|
||||
])
|
||||
);
|
||||
drag((e) => dragSkill(e, "ponytail:ponytail-help"));
|
||||
expect(screen.getByTestId("sent").textContent).toBe(
|
||||
"/ponytail:ponytail-help check it @/a/x.md /d/drops/0123456789abcdef.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a text paste to the textarea", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file TerminalView.test.tsx
|
||||
* @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
|
||||
* 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
|
||||
* after `exit` or a policy close,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
@@ -21,6 +22,9 @@ const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = 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();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
@@ -69,6 +73,10 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
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 }),
|
||||
})),
|
||||
}));
|
||||
@@ -223,6 +231,24 @@ describe("TerminalView", () => {
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the wheel itself once the server says its tmux never tracks the mouse", () => {
|
||||
// psmux (Windows) never turns mouse tracking on for its client, so xterm
|
||||
// never reports the wheel and the pane could not be scrolled at all.
|
||||
termState.modes.mouseTrackingMode = "none";
|
||||
termState.buffer.active.type = "alternate";
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||||
ws.onmessage?.({ data: '{"type":"wheel"}' });
|
||||
const wheel = wheelHandlerMock.mock.calls[wheelHandlerMock.mock.calls.length - 1]![0]!;
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
||||
// Tracked again (a tmux that does report): xterm sends it, never twice.
|
||||
termState.modes.mouseTrackingMode = "any";
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
||||
expect(ws.sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
@@ -503,4 +529,124 @@ 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();
|
||||
});
|
||||
|
||||
// ^Z suspended claude: the pane fell to its shell and the session looked
|
||||
// closed. Ctrl+Z is undo in a browser, so it sends Ctrl+_ instead.
|
||||
it("turns Ctrl+Z into undo (Ctrl+_) and never sends ^Z, which would suspend the session", () => {
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
const ev = new KeyboardEvent("keydown", { key: "z", ctrlKey: true, cancelable: true });
|
||||
expect(handler(ev)).toBe(false);
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(inputMock).toHaveBeenCalledWith("\x1f", true);
|
||||
expect(inputMock).not.toHaveBeenCalledWith("\x1a", expect.anything());
|
||||
// Other chords with Z are left to xterm (Ctrl+Shift+Z, plain z).
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "Z", ctrlKey: true, shiftKey: true }))).toBe(
|
||||
true
|
||||
);
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "z" }))).toBe(true);
|
||||
});
|
||||
|
||||
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
|
||||
// every later key REPLACES the word — the event order captured on a Mac.
|
||||
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
|
||||
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,432 @@
|
||||
/**
|
||||
* @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 avatar
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
|
||||
* sidebar shows it at once), its display name
|
||||
* and email (`PATCH /api/me/account`), a password change that asks for the
|
||||
* current one (`POST /api/me/password` — this browser stays signed in, every
|
||||
* other sign-in ends) and "Sign out other devices" (`POST
|
||||
* /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,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type MyAccount } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { ROLE_BADGE } from "../users/UserEditDialog";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
|
||||
|
||||
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 name and avatar: let them follow a save at once.
|
||||
updateUser({
|
||||
displayName: r.user.displayName,
|
||||
email: r.user.email,
|
||||
avatarAt: r.user.avatarAt ?? null,
|
||||
});
|
||||
},
|
||||
[updateUser]
|
||||
);
|
||||
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} onSaved={apply} />
|
||||
<ProfileForm account={account} onSaved={apply} />
|
||||
<PasswordForm />
|
||||
<SignOutOthers logins={account.logins} onDone={load} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The largest avatar the server takes (server/lib/avatars.js). */
|
||||
const MAX_AVATAR_BYTES = 1024 * 1024;
|
||||
|
||||
function ProfileCard({
|
||||
account,
|
||||
onSaved,
|
||||
}: {
|
||||
account: ServerAccount;
|
||||
onSaved: (r: MyAccount) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { user } = account;
|
||||
const shown = user.displayName || user.username;
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async (op: () => Promise<MyAccount>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onSaved(await op());
|
||||
toast.success(done);
|
||||
} catch (e) {
|
||||
toast.error(errText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const pick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // the same file can be picked again
|
||||
if (!file) return;
|
||||
if (file.size > MAX_AVATAR_BYTES) {
|
||||
toast.error(t("account.avatar.tooBig"));
|
||||
return;
|
||||
}
|
||||
void run(() => api.me.setAvatar(file), t("account.avatar.saved"));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card p-5" data-testid="account-card">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-shrink-0 flex-col items-center gap-1.5">
|
||||
<UserAvatar name={shown} avatarAt={user.avatarAt} className="h-14 w-14 text-lg" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost h-6 w-6 justify-center p-0"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
title={t("account.avatar.change")}
|
||||
aria-label={t("account.avatar.change")}
|
||||
data-testid="account-avatar-change"
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{user.avatarAt && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost h-6 w-6 justify-center p-0"
|
||||
onClick={() => void run(() => api.me.removeAvatar(), t("account.avatar.removed"))}
|
||||
disabled={busy}
|
||||
title={t("account.avatar.remove")}
|
||||
aria-label={t("account.avatar.remove")}
|
||||
data-testid="account-avatar-remove"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={pick}
|
||||
data-testid="account-avatar-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
@{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,8 +1,13 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* @description Settings → Appearance (`/settings/appearance`), grouped so
|
||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`); then the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -17,7 +22,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { ChatUiSettings } from "./ChatUiSettings";
|
||||
import { Select } from "../Select";
|
||||
@@ -31,6 +36,16 @@ import {
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
readTerminalFontSize,
|
||||
readUiFontSize,
|
||||
writeTerminalFontSize,
|
||||
writeUiFontSize,
|
||||
type UiFontSize,
|
||||
} from "../../lib/fontSize";
|
||||
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
@@ -79,6 +94,9 @@ export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
@@ -99,15 +117,11 @@ export function AppearanceSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ChatUiSettings />
|
||||
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
description={t("language.description")}
|
||||
>
|
||||
<SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
|
||||
<SettingsRow
|
||||
id="language"
|
||||
label={t("language.label")}
|
||||
description={t("language.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
@@ -120,16 +134,34 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="terminal-font"
|
||||
title={t("terminalFont.title")}
|
||||
description={t("terminalFont.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
id="text-size"
|
||||
label={t("textSize.label")}
|
||||
description={t("textSize.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("textSize.label")}
|
||||
value={String(uiSize)}
|
||||
onChange={(v) => {
|
||||
const size = Number(v) as UiFontSize;
|
||||
writeUiFontSize(size);
|
||||
setUiSize(size);
|
||||
}}
|
||||
options={UI_FONT_SIZES.map((size) => ({
|
||||
value: String(size),
|
||||
label: t(`textSize.names.${size}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
id="terminal-font"
|
||||
label={t("terminalFont.title")}
|
||||
description={
|
||||
termFont === "system"
|
||||
? `${t("terminalFont.description")} ${t("terminalFont.systemNote")}`
|
||||
: t("terminalFont.description")
|
||||
}
|
||||
control={
|
||||
// A dropdown: five font names overflow a segmented row.
|
||||
<Select<TerminalFont>
|
||||
@@ -143,17 +175,57 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.sizeLabel")}
|
||||
control={
|
||||
<Select
|
||||
label={t("terminalFont.sizeLabel")}
|
||||
value={String(termSize)}
|
||||
onChange={(v) => {
|
||||
writeTerminalFontSize(Number(v));
|
||||
setTermSize(Number(v));
|
||||
}}
|
||||
options={TERMINAL_FONT_SIZES.map((size) => ({
|
||||
value: String(size),
|
||||
label: `${size}px`,
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p
|
||||
data-testid="terminal-font-preview"
|
||||
aria-label={t("terminalFont.preview")}
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-fg-primary"
|
||||
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||||
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
|
||||
// In px like xterm's cells: the interface's text size does not scale it.
|
||||
style={{
|
||||
fontFamily: TERMINAL_FONTS[termFont],
|
||||
fontSize: termSize,
|
||||
fontVariantLigatures: "none",
|
||||
}}
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="sessions-view"
|
||||
title={t("sessionsView.title")}
|
||||
description={t("sessionsView.description")}
|
||||
>
|
||||
<ChatUiSettings />
|
||||
<SettingsSwitchRow
|
||||
id="workspace-tabs"
|
||||
label={t("workspaceTabs.label")}
|
||||
description={t("workspaceTabs.labelDesc")}
|
||||
checked={allTabs}
|
||||
onChange={(on) => {
|
||||
writeAllTabs(on);
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
|
||||
* admin's server-wide switch that allows or blocks installing a shared skill
|
||||
* or plugin from the Hub into a user's own account (`GET/PUT
|
||||
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
||||
* or for anyone the server does not let manage it (`canManage`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type ArtifactSettings } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function ArtifactsHubSettings() {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.artifacts
|
||||
.settings()
|
||||
.then(setSettings)
|
||||
.catch(() => setSettings(null));
|
||||
}, []);
|
||||
|
||||
if (!settings?.serverMode || !settings.canManage) return null;
|
||||
return (
|
||||
<SettingsSection id="artifacts-hub" title={t("title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("installsSwitch")}
|
||||
description={t("installsSwitchHint")}
|
||||
checked={settings.installsEnabled}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
const r = await api.artifacts.setInstallsEnabled(v);
|
||||
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
|
||||
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
|
||||
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* @file ChatUiSettings — Settings → Appearance → Sessions, the Chat UI row
|
||||
* (`#chat-ui`): whether a Claude or Codex session opens as a chat like Claude
|
||||
* Desktop (`PaneChat`) instead of its terminal. A row, not a card: it sits in
|
||||
* the Sessions section beside the workspace-tabs switch. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* account on a shared dashboard keeps its own choice on any browser.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -9,25 +10,22 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { saveChatUi, useChatUi } from "../../lib/chatUi";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function ChatUiSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const chatUi = useChatUi();
|
||||
return (
|
||||
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(
|
||||
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSwitchRow
|
||||
id="chat-ui"
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }))
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* @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, the email else `<username>@smartgift.vn`,
|
||||
* since a member's commits fail without one; a new SSH key is labelled with
|
||||
* that email), the SSH
|
||||
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||
* it, and whether git typed in the account's own terminal uses it too (a
|
||||
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
|
||||
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
|
||||
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
|
||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||
* kind of host, the extra keys, the other hosts) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { PageTabs } from "../PageTabs";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
const BTN =
|
||||
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
|
||||
// The regions AWS CodeCommit runs in.
|
||||
const AWS_REGIONS = [
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2",
|
||||
"ca-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"sa-east-1",
|
||||
"me-south-1",
|
||||
"af-south-1",
|
||||
"il-central-1",
|
||||
];
|
||||
|
||||
function Step({ n, children }: { n: number; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2 text-[11px] text-fg-secondary">
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-3 text-[10px] text-fg-primary">
|
||||
{n}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||
|
||||
/**
|
||||
* One kind of git host and the key it takes: no key → a Generate button;
|
||||
* a key → step 1 (copy its public half) followed by the host's own steps.
|
||||
*/
|
||||
function KeyCard({
|
||||
title,
|
||||
sub,
|
||||
type,
|
||||
k,
|
||||
id,
|
||||
busy,
|
||||
onGenerate,
|
||||
onRemove,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
sub: string;
|
||||
type: "ed25519" | "rsa";
|
||||
k: Key | undefined;
|
||||
id: GitIdentity;
|
||||
busy: boolean;
|
||||
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<section aria-label={title} className="space-y-2 rounded border border-border p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||
</div>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => void onGenerate(type)}
|
||||
>
|
||||
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
{k && id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void onRemove(type)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{k ? (
|
||||
<>
|
||||
<Step n={1}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1">{t("gitIdentity.stepCopy")}</span>
|
||||
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
||||
value={k.publicKey}
|
||||
rows={type === "rsa" ? 3 : 2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
<div className="truncate font-mono text-[10px] text-fg-muted">{k.fingerprint}</div>
|
||||
</Step>
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
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);
|
||||
// null = not picked: opens on the first kind of host that has a key.
|
||||
const [sshTab, setSshTab] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setId(await api.gitIdentity.get());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<GitIdentity>, done?: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setId(await fn());
|
||||
if (done) toast.success(done);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||
// An account may hold one of each; an older server answers `ssh` only.
|
||||
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
||||
// The default key of a type (a named key sits beside it).
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||
const named = keys.filter((k) => k.name);
|
||||
const keyLabel = (keyId: string | null | undefined) => {
|
||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||
const makeNamed = async () => {
|
||||
if (!newKey) return;
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKeyById(keyId));
|
||||
};
|
||||
const generate = async (type: "ed25519" | "rsa") => {
|
||||
const taken = !!keyOf(type);
|
||||
if (
|
||||
taken &&
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.replaceConfirm"),
|
||||
confirmLabel: t("gitIdentity.replace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
|
||||
};
|
||||
|
||||
const removeKey = async (type: "ed25519" | "rsa") => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKey(type));
|
||||
};
|
||||
|
||||
const saveLogin = async () => {
|
||||
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);
|
||||
};
|
||||
|
||||
// The SSH login name per host (`url.<…>.insteadOf`), so a plain
|
||||
// `ssh://host/…` URL logs in. AWS CodeCommit's (user = the IAM SSH key ID,
|
||||
// one host per region) gets its own form under the RSA key; anything else
|
||||
// is the "other hosts" form.
|
||||
const sshUsers = id?.sshUsers ?? [];
|
||||
const awsRows = sshUsers.flatMap((r) => {
|
||||
const m = CODECOMMIT_RE.exec(r.host);
|
||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||
});
|
||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||
const saveAws = async () => {
|
||||
let user = aws.keyId.trim();
|
||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||
if (m?.[1] && m[2]) {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
}
|
||||
const alias = aws.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(aws.key ? { key: aws.key } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
);
|
||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
||||
};
|
||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||
const saveOther = async () => {
|
||||
let host = other.host.trim();
|
||||
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
|
||||
if (m?.[1]) host = m[1];
|
||||
const alias = other.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user: other.user.trim(),
|
||||
...(alias ? { alias } : {}),
|
||||
...(other.key ? { key: other.key } : {}),
|
||||
}),
|
||||
t("gitIdentity.otherSaved", { host: alias || host })
|
||||
);
|
||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||
};
|
||||
/** The key picker of a login form: every key, or one. */
|
||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
||||
<select
|
||||
aria-label={t("gitIdentity.loginKey")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||
{keys
|
||||
.filter((k) => k.id && (!only || k.type === only))
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
const removeLogin = async (host: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
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 ?? id.defaultEmail ?? "",
|
||||
};
|
||||
const authorUnset = !id.author.name || !id.author.email;
|
||||
const authorDirty =
|
||||
authorUnset ||
|
||||
authorShown.name.trim() !== (id.author.name ?? "") ||
|
||||
authorShown.email.trim() !== (id.author.email ?? "");
|
||||
// The SSH keys, one tab per kind of host — every section at once was a wall.
|
||||
const sshTabs = [
|
||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||
...(named.length > 0 || id.writable
|
||||
? [
|
||||
{
|
||||
id: "more",
|
||||
label: named.length
|
||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
||||
: t("gitIdentity.tabMore"),
|
||||
Icon: KeySquare,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||
: []),
|
||||
];
|
||||
const activeSshTab =
|
||||
sshTab && sshTabs.some((x) => x.id === sshTab)
|
||||
? sshTab
|
||||
: !keyOf("ed25519") && keyOf("rsa")
|
||||
? "aws"
|
||||
: "git";
|
||||
const saveAuthor = async () => {
|
||||
const ok = await run(
|
||||
() =>
|
||||
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="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="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="space-y-3 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.sshTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
|
||||
|
||||
<PageTabs
|
||||
ariaLabel={t("gitIdentity.sshTitle")}
|
||||
tabs={sshTabs}
|
||||
activeId={activeSshTab}
|
||||
onSelect={setSshTab}
|
||||
>
|
||||
{activeSshTab === "git" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
{activeSshTab === "aws" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={aws.keyId}
|
||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={aws.region}
|
||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{awsRows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
{activeSshTab === "more" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSshTab === "other" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.otherTitle")}
|
||||
className="space-y-1.5 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.otherTitle")}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
||||
{otherRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `ssh://${r.alias}/ → ` : ""}
|
||||
{r.user}@{r.host} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveOther();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherHost")}
|
||||
placeholder="git.example.com"
|
||||
value={other.host}
|
||||
onChange={(e) => setOther({ ...other, host: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherUser")}
|
||||
placeholder={t("gitIdentity.otherUser")}
|
||||
autoComplete="off"
|
||||
value={other.user}
|
||||
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={other.alias}
|
||||
onChange={(e) => setOther({ ...other, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(other.key, (key) => setOther({ ...other, key }))}
|
||||
<button
|
||||
type="submit"
|
||||
className={BTN}
|
||||
disabled={busy || !other.host.trim() || !other.user.trim()}
|
||||
>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</PageTabs>
|
||||
|
||||
{terminal && (
|
||||
<div className="flex items-start gap-2 rounded border border-border p-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
|
||||
<div className="text-[10px] text-fg-muted">
|
||||
{terminal === "foreign"
|
||||
? t("gitIdentity.terminalForeign")
|
||||
: t("gitIdentity.terminalHint")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
label={t("gitIdentity.terminal")}
|
||||
checked={terminal === "on"}
|
||||
disabled={busy || !id.writable || terminal === "foreign"}
|
||||
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
|
||||
</div>
|
||||
|
||||
{member && (
|
||||
<div className="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 { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import {
|
||||
ArrowRight,
|
||||
DollarSign,
|
||||
@@ -208,6 +209,7 @@ export type PricingVendor = "claude" | "openai" | "google";
|
||||
*/
|
||||
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const href = useSettingsHref();
|
||||
return (
|
||||
<div className="card flex items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0">
|
||||
@@ -217,7 +219,7 @@ export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||
</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")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
|
||||
@@ -37,15 +37,19 @@ export function SettingsRow({
|
||||
description,
|
||||
control,
|
||||
alignTop = false,
|
||||
id,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
control: ReactNode;
|
||||
alignTop?: boolean;
|
||||
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
id={id}
|
||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
description ? "py-3" : "py-2"
|
||||
} ${alignTop ? "items-start" : "items-center"}`}
|
||||
>
|
||||
@@ -64,15 +68,18 @@ export function SettingsSwitchRow({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
id,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<SettingsRow
|
||||
id={id}
|
||||
label={label}
|
||||
description={description}
|
||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @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, asks before signing
|
||||
* the other devices out, and uploads/removes the avatar.
|
||||
* @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(),
|
||||
setAvatar: vi.fn(),
|
||||
removeAvatar: vi.fn(),
|
||||
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
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)) if ("mockReset" in fn) 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("uploads a picked avatar and removes it again", async () => {
|
||||
const ue = userEvent.setup();
|
||||
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
|
||||
me.setAvatar.mockResolvedValue(withAvatar);
|
||||
me.removeAvatar.mockResolvedValue(account);
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
|
||||
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
|
||||
await ue.upload(screen.getByTestId("account-avatar-input"), file);
|
||||
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
|
||||
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
|
||||
"/api/me/avatar?v=2026-10-14T00:00:00Z"
|
||||
);
|
||||
await ue.click(screen.getByTestId("account-avatar-remove"));
|
||||
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
|
||||
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the account's own information", async () => {
|
||||
render(<AccountSettings />);
|
||||
const card = await screen.findByTestId("account-card");
|
||||
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());
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user