Compare commits

...

8 Commits

Author SHA1 Message Date
nntrivi2001 96e4d28f7c fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat
- #69: tmux applies a pane's `-c` only while its server's own cwd exists.
  The server was forked from the dashboard's cwd (the plugin cache dir),
  outlives updates, and the old cache dir is pruned — after that every new
  pane started in a deleted directory, where `claude` died at once ("run not
  found") and a shell printed getcwd errors. tmux calls now run from `/`,
  and the pane's sh wrapper cds into its folder itself, which also heals a
  tmux server that is already stuck.
- Changes panel: a workspace's extra folders are sections stacked under the
  workspace folder (VS Code Source Control style), with Close repository /
  Close other repositories on a header's right-click.
- Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]`
  line folds into its turn; uploads under `<data dir>/drops/` are listed.
- Update check: a checkout holding commits a newer release lacks reports
  `diverged_from_release` instead of offering a fast-forward that can only fail.
2026-10-09 17:24:14 +07:00
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00
nntrivi2001 ce773a3f5a Merge branch 's3-async-tmux' into s3-followups 2026-10-09 15:21:57 +07:00
nntrivi2001 4407b3bb16 fix(server-s3): list a member's tmux panes asynchronously
A frozen member (SIGSTOPped tmux server) cost every user one synchronous
3 s block of the event loop per minute (F37's back-off ceiling). Member
listing now runs through async execFile (same 3 s / SIGKILL / 1 MiB
bounds, same back-off) into the listed cache the synchronous readers
(liveSessions, listSessions, listOutput) already use: stale-while-
revalidate, 15 s max age, cold or failing = that member's panes UNKNOWN
(F38 unchanged). A session this process creates or kills is written into
the member's cached answer at once, and a list in flight across that
change is dropped. Host tmux calls, local mode and every click's call
(new-session, send-keys, kill, has-session) are unchanged.

review5's stalled-member test now polls for 4 s past the timeout and
asserts each poll < 100 ms, event-loop stall < 500 ms and one sudo
(old code: first poll 3157 ms).
2026-10-09 15:21:31 +07:00
nntrivi2001 44fd664670 Merge branch 's3-async-copy' into s3-followups 2026-10-09 15:18:57 +07:00
nntrivi2001 118eaee2ec docs(server-mode): plan moved in from the deleted AgentHub-Server repo; P6 spike result; Express patch only on 4.x
- docs/plans/server-mode/README.md: the original multi-user design, now kept
  here (AgentHub is the one source); desktop-parity and S3-PLAN link to it.
- S3-PLAN P6: managed-settings spike run on agh-test1 — the managed
  marketplace/plugin arrive at the first interactive session (onboarding
  seeded), never via the CLI alone, and no user-scope record is written.
- server/index.js: the Layer.handle_request patch applies only on Express 4
  (Express 5 handles async rejections itself and has no lib/router/layer).
2026-10-09 15:18:57 +07:00
nntrivi2001 3ab750c765 fix(server-s3): transcript snapshot copies asynchronously (F42 follow-up)
copyIfNewer read/wrote a member file of up to 256 MiB with readSync/writeSync,
blocking the event loop for every user. It now copies with fs.promises 1 MiB
at a time from the runAs.openSafeAsync handle (owner/type check kept), keeps
the 256 MiB member cap and zero-run holes, writes a temp file and renames it
into place, and joins a copy already in flight for the same destination.
A failed copy is logged and never rejects; snapshotTranscript is async and
all three import callers await it.
2026-10-09 15:17:55 +07:00
nntrivi2001 84e2a9f0ac feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>)
when `agenthub server install-system` has set S3 up. The dashboard
reaches a member only through sudo -u / write-as / pane-launch, reads a
member's files only through owner- and type-checked opens, and hooks
are authenticated by peer uid. Local mode and server mode without S3
are unchanged.

EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not
yet been verified on a real install with real member accounts, sudo or
a real Codex login. Do not offer it to real users yet.

- S3 core: provisioning, per-member tmux servers, the proxy key
  gateway, shared tools in /opt, dropped capabilities for children
  (P0–P5)
- P4: plan and quota per member home
- P6: ~/.claude.json seed; opt-in plugin baseline installed as the
  member; managed-settings spike script (not run)
- P7: Codex account pool (`agenthub accounts add --tool codex`)
- Roles merged with main's admin / analyst / developer: analysts write
  only their own rows, under S3 as well
- Per-account prefs: one implementation (Chat UI + onboarding)
- Security reviews F1–F49 (five rounds), each fix with a regression
  test: symlink/TOCTOU-safe member reads, no host writes in a member
  home, credentials and the data dir never served, owner and folder
  account must agree, pane tokens bound to the pane's uid, bounded
  sudo/tmux/git calls and member reads (no hang or crash from a member),
  async route rejections answer 500 instead of exiting
- Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator
  runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules

Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box);
client 1162/1162; tsc clean; headers check passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 14:57:10 +07:00
211 changed files with 18687 additions and 1563 deletions
+3 -1
View File
@@ -374,7 +374,7 @@ graph TD
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `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 `<tmpdir>/agenthub-drops-<uid>/`) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `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 |
@@ -2244,6 +2244,7 @@ interface UpdateStatusPayload {
situation?: // categorical hint for the UI
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
| "release_available" // fast-forward onto the release tag
| "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
| "tracking_canonical"
| "fork_or_diverged_tracking"
| "feature_branch"
@@ -2309,6 +2310,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
| Newer release, but HEAD holds commits the release lacks (e.g. local wip the release squashed) | `{update_available:true, situation:"diverged_from_release", manual_command:"...git fetch <remote> --tags"}` | Modal opens with no **Update now**; `situation_note` says how many commits diverge and offers `git reset --keep <tag>` (when they are already in the release) or merge/rebase. `/apply` answers `not_fast_forwardable` instead of a raw `merge --ff-only` failure. |
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
### Why Self-Apply Stays Narrow
+5 -4
View File
File diff suppressed because one or more lines are too long
+223 -8
View File
@@ -2853,8 +2853,8 @@ const COMMAND_GROUPS = [
],
[
"accounts",
"list|add|remove [name]",
"Claude account pool: new sessions spread over these accounts",
"list|add|remove [name] [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
@@ -3952,6 +3952,23 @@ async function cmdServer(sub, flags) {
);
return;
}
if (sub === "install-system") return installSystem(flags, settingsEnv);
if (sub === "tools") {
// S3/S4: copy this install's scripts and every installed AI tool under
// /opt/agenthub, where every account's pane runs them (the server also
// does this at start and after each install/update).
const shared = require(path.join(REPO_ROOT, "server", "lib", "shared-tools.js"));
await shared.publishApp(REPO_ROOT);
const rows = await shared.publishAll();
table(
["tool", "shared copy"],
rows.map((r) => [
r.id,
r.error ? c.red(r.error) : `${r.path}${r.copied ? "" : c.dim(" (unchanged)")}`,
])
);
return;
}
if (sub === "off") {
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
envNote("AGENTHUB_MODE");
@@ -3969,11 +3986,120 @@ async function cmdServer(sub, flags) {
return;
}
console.error(
c.red("✖ Usage: agenthub server setup [--hostname name] [--admin name] | off | status")
c.red(
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
)
);
process.exitCode = 1;
}
/**
* `agenthub server install-system` — one Linux user per account (S3,
* docs/SERVER-MODE.md). Run as the HOST account, never as root: it resolves
* this install's paths as the host, hands them to the root half
* (`scripts/server/install-system.sh`) through ONE `sudo`, points the system
* unit at this install, and moves the dashboard from the `systemd --user`
* unit to the system one (panes survive: KillMode=process). `--uninstall`
* goes back to the user unit.
*/
async function installSystem(flags, settingsEnv) {
const os = require("node:os");
if (process.getuid && process.getuid() === 0) {
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
}
const which = (bin) =>
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
const host = os.userInfo().username;
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
const data = getDataDir();
const port = String(process.env.DASHBOARD_PORT || "4820");
const script = path.join(REPO_ROOT, "scripts", "server", "install-system.sh");
const args = flags.uninstall
? [script, "--uninstall", "--host", host]
: [
script,
...["--host", host, "--node", process.execPath, "--root", REPO_ROOT, "--data", data],
...["--port", port, "--tmux", which("tmux"), "--git", which("git")],
...(which("gh") ? ["--gh", which("gh")] : []),
];
if (flags["dry-run"]) {
console.log(["sudo", "bash", ...args].join(" "));
return;
}
const userUnit = (...a) => spawnSync("systemctl", ["--user", ...a, "agenthub.service"]);
// What either unit runs: this install's node and server, with its env.
const runtime = path.join(data, "runtime");
const clientDist = fs.existsSync(path.join(REPO_ROOT, "client", "dist", "index.html"))
? path.join(REPO_ROOT, "client", "dist")
: path.join(runtime, "client-dist");
const env = { NODE_ENV: "production", PATH: process.env.PATH || "", DASHBOARD_PORT: port };
env.DASHBOARD_CLIENT_DIST = clientDist;
if (!fs.existsSync(path.join(REPO_ROOT, "node_modules"))) {
env.NODE_PATH = path.join(runtime, "node_modules");
}
const serverArgv = [process.execPath, path.join(REPO_ROOT, "server", "index.js")];
if (flags.uninstall) {
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
throw new Error("install-system --uninstall failed");
}
// Repoint the user unit first: the build it named may have been pruned
// while the system unit was the one protected.
require(path.join(REPO_ROOT, "scripts", "systemd-service.js")).writeUserService({
argv: serverArgv,
env,
});
userUnit("enable", "--now");
console.log(`${c.green("✔")} back on the systemd --user unit`);
return;
}
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
throw new Error("install-system failed — nothing was started");
}
// The unit reads node, the plugin root and the env from a file this account
// owns (the bootstrap repoints it after an update without root).
const sys = require(path.join(REPO_ROOT, "scripts", "systemd-system-service.js"));
const wrote = sys.writeUserService({ argv: serverArgv, env });
if (wrote === "failed" || wrote === "unavailable") {
throw new Error(`could not write ${sys.envPath()} (${wrote})`);
}
settingsEnv.writeSetting(
"AGENTHUB_USERS_ROOT",
require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js")).serverConf().usersRoot
);
// Hand over: the user unit stops (its panes stay), and so does a dashboard
// started without one (detached) — else the system unit finds the port
// taken and the old process, without the S3 groups, keeps serving.
userUnit("disable", "--now");
const boot = require(path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"));
const sysUp = () =>
spawnSync("systemctl", ["is-active", "agenthub.service"], {
encoding: "utf8",
}).stdout.trim() === "active";
// A re-run while the system unit serves: that server is the one to keep.
if (!sysUp()) boot.stopDashboard();
for (let i = 0; i < 50 && !sysUp() && boot.livePids().length; i++) {
spawnSync("sleep", ["0.2"]);
}
if (!sysUp() && boot.livePids().length) {
throw new Error("a dashboard is still running — stop it first");
}
if (spawnSync("sudo", ["-n", "systemctl", "enable", "agenthub.service"]).status !== 0) {
console.log(c.yellow("! could not enable agenthub.service for boot"));
}
if (spawnSync("sudo", ["-n", "systemctl", "start", "--no-block", "agenthub.service"]).status) {
throw new Error("could not start agenthub.service — `sudo systemctl status agenthub`");
}
spawnSync("sleep", ["3"]);
if (!sysUp()) {
throw new Error("agenthub.service did not stay up — `sudo journalctl -u agenthub -n 50`");
}
console.log(`${c.green("✔")} the dashboard now runs as the system unit agenthub.service`);
console.log(" Give the existing users their Linux accounts: agenthub users provision --all");
console.log(
c.dim(" Log out and back in once: your own session joins groups agenthub/agenthub-proc.")
);
}
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
async function cmdUsers(sub, positional, flags) {
const { auth } = serverAuth();
@@ -4026,6 +4152,37 @@ async function cmdUsers(sub, positional, flags) {
);
return;
}
case "provision": {
// S3: the Linux account of a user made before install-system (or after a
// failed create). Idempotent: an existing member account is kept.
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
if (!linux.s3Enabled()) {
throw new Error(
"one Linux user per account is not installed (agenthub server install-system)"
);
}
const names = flags.all
? auth
.listUsers()
.filter((u) => !u.disabled)
.map((u) => u.username)
: (need(), [name]);
const memberClaude = require(path.join(REPO_ROOT, "server", "lib", "member-claude.js"));
for (const n of names) {
// One user that cannot be provisioned (a name Linux refuses, a folder
// already there) is reported and skipped — never the end of the run.
try {
console.log(`${c.green("✔")} ${linux.provision("create", n)}`);
} catch (e) {
console.error(c.red(`✖ ${n}: ${e.message}`));
process.exitCode = 1;
continue;
}
// Opt-in plugin baseline (/etc/agenthub/plugin-baseline.json); prints per entry.
await memberClaude.applyPluginBaseline(n);
}
return;
}
case "disable":
case "enable":
need();
@@ -4035,21 +4192,75 @@ async function cmdUsers(sub, positional, flags) {
default:
console.error(
c.red(
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | provision <name>|--all | assign <workspace id> <name> | audit [--limit N]"
)
);
process.exitCode = 1;
}
}
/** `agenthub accounts … --tool codex` — the Codex account pool (server/lib/codex-pool.js). */
async function cmdCodexAccounts(sub, name, flags) {
const pool = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
if (sub === "add") {
let added;
if (flags["api-key"]) {
added = pool.addAccount(name, { apiKey: await askPassword(`OpenAI API key for ${name}: `) });
} else if (typeof flags.from === "string") {
// A `codex login` home (or its auth.json) — read here, on the host's shell.
const from = path.resolve(flags.from);
const file = fs.statSync(from).isDirectory() ? path.join(from, "auth.json") : from;
added = pool.addAccount(name, { auth: JSON.parse(fs.readFileSync(file, "utf8")) });
console.log(c.dim(` You can delete ${file} now; the pool keeps its own copy.`));
} else {
throw new Error(
"usage: agenthub accounts add <name> --tool codex --api-key | --from <dir>\n" +
" A ChatGPT account: `CODEX_HOME=$(mktemp -d) codex login` signed in as it, then --from that dir."
);
}
console.log(`${c.green("✔")} ${added.name} (${added.kind}) added`);
console.log(c.dim(" New Codex sessions now spread over the pool's accounts."));
return;
}
if (sub === "remove") {
pool.removeAccount(name);
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
return;
}
if (sub !== undefined && sub !== "list") {
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool codex");
}
pool.resetQuota();
table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
pool
.poolStatus()
.map((a) => [
a.name,
a.kind,
a.livePanes,
a.spend5h.toFixed(2),
a.spend7d.toFixed(2),
a.quota
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
: "–",
a.full ? "full" : "ok",
])
);
}
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
async function cmdAccounts(sub, positional) {
async function cmdAccounts(sub, positional, flags = {}) {
serverAuth(); // the same settings + database the server reads
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
const name = positional[0];
if (sub === "add" || sub === "remove") {
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name>`);
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
}
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
throw new Error("--tool is claude (the default) or codex");
}
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (sub === "add") {
console.log(
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
@@ -4089,9 +4300,13 @@ async function cmdAccounts(sub, positional) {
: "ok",
])
);
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
return;
}
console.error(c.red("✖ Usage: agenthub accounts list | add <name> | remove <name>"));
console.error(
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
);
process.exitCode = 1;
}
@@ -4121,7 +4336,7 @@ async function runCommand(argv) {
case "users":
return cmdUsers(positional[0], positional.slice(1), flags);
case "accounts":
return cmdAccounts(positional[0], positional.slice(1));
return cmdAccounts(positional[0], positional.slice(1), flags);
case "stats":
return cmdStats();
case "kanban":
+9 -9
View File
@@ -30,7 +30,7 @@
<title>AgentHub - SmartGift Solution</title>
<meta
name="description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta
name="keywords"
@@ -45,23 +45,23 @@
<meta property="og:title" content="AgentHub - SmartGift Solution" />
<meta
property="og:description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta property="og:image" content="/og-image.svg" />
<meta property="og:image" content="/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
<meta
name="twitter:description"
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta name="twitter:image" content="/og-image.svg" />
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta name="twitter:image" content="/og-image.png" />
<meta name="twitter:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
<meta name="twitter:creator" content="@smartgift" />
<!-- JSON-LD structured data -->
@@ -70,7 +70,7 @@
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "AgentHub",
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
"description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
"softwareVersion": "1.0.0",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+106 -32
View File
@@ -8,6 +8,9 @@
* (`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`;
* `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`).
* The login page follows the OS's light/dark (`applySystemTheme`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -15,12 +18,17 @@ import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { BRAND_LOGO } from "../lib/brand";
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> };
@@ -114,6 +122,18 @@ export function AuthGate({ children }: { children: ReactNode }) {
};
}, []);
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
// per account, so a second login in this browser brings that account's.
const account =
state?.mode === "local"
? "local"
: state?.mode === "server" && state.user
? `u${state.user.id}`
: null;
useEffect(() => {
if (account) void hydrate();
}, [account]);
if (!state) return null;
if (state.mode === "server" && !state.user) {
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
@@ -129,9 +149,18 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Nobody is signed in, so no account's theme applies: follow the OS, live.
useLayoutEffect(() => {
applySystemTheme();
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
mq?.addEventListener?.("change", applySystemTheme);
return () => mq?.removeEventListener?.("change", applySystemTheme);
}, []);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
@@ -149,38 +178,83 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
<label className="block space-y-1 text-sm text-fg-secondary">
<span>{t("auth.username")}</span>
<input
className="input w-full"
autoComplete="username"
autoFocus
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label className="block space-y-1 text-sm text-fg-secondary">
<span>{t("auth.password")}</span>
<input
className="input w-full"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
<button type="submit" className="btn-primary w-full" disabled={busy}>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
<form
onSubmit={submit}
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
>
{/* Accent glow and a grid fading out from the top, drawn from the
theme's own accent so it holds in every theme. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-56"
style={{
backgroundImage:
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
backgroundPosition: "center top",
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
}}
/>
<div className="relative space-y-5">
<div className="text-center">
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
{t("auth.title")}
</h1>
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
</div>
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
<span>{t("auth.username")}</span>
<input
className="input h-11 w-full px-4"
autoComplete="username"
autoFocus
required
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
<label htmlFor="login-password">{t("auth.password")}</label>
<div className="relative">
<input
id="login-password"
className="input h-11 w-full pl-4 pr-11"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{/* Inverted fill (text colour behind the surface colour): black in a
light theme, light in a dark one, like the reference design. */}
<button
type="submit"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
disabled={busy}
>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
</div>
</form>
</main>
);
+44
View File
@@ -0,0 +1,44 @@
/**
* @file ImageLightbox.tsx
* @description One image shown full size over the whole page; a click or
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
* layer or a header drawn over a pane can cover it — opened from inside a
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
* layer, under anything stacked above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { createPortal } from "react-dom";
export function ImageLightbox({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return createPortal(
<div
role="dialog"
aria-label={alt}
data-testid="image-lightbox"
onClick={onClose}
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={src}
alt={alt}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>,
document.body
);
}
+4
View File
@@ -83,6 +83,8 @@ import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
import { OnboardingTour } from "./onboarding/OnboardingTour";
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
@@ -172,6 +174,8 @@ export function Layout({ wsConnected }: LayoutProps) {
</a>
<UpdateNotifier />
<SagiMount />
<OnboardingWizard />
<OnboardingTour />
{drawer && drawerOpen && (
<div
aria-hidden
+5 -1
View File
@@ -12,7 +12,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
@@ -27,12 +27,15 @@ export function PathPickButton({
start,
onPick,
showLabel = false,
buttonRef,
}: {
kind?: "dir" | "file";
/** The field's current value — the picker opens there. */
start?: string;
onPick: (path: string) => void;
showLabel?: boolean;
/** Lets a field beside it open the same picker (a click on an empty path). */
buttonRef?: Ref<HTMLButtonElement>;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false);
@@ -61,6 +64,7 @@ export function PathPickButton({
return (
<>
<button
ref={buttonRef}
type="button"
onClick={pick}
disabled={busy}
+15 -4
View File
@@ -3,7 +3,8 @@
* @description Generic styled dropdown that replaces native `<select>` elements
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
* checkmark space inconsistently; this control aligns labels, flips above the
* trigger when viewport space is tight, and marks the chosen option with accent
* trigger when space is tight — in its split pane (`data-menu-bounds`, see
* `menuRoom`) or else the viewport — and marks the chosen option with accent
* color plus a Lucide check.
*
* ## Accessibility — WAI-ARIA "listbox button"
@@ -74,6 +75,14 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
* over anything crossing its edge — else the viewport. */
export function menuRoom(el: Element, r: DOMRect): { above: number; below: number } {
const box = el.closest("[data-menu-bounds]")?.getBoundingClientRect();
return { above: r.top - (box?.top ?? 0), below: (box?.bottom ?? window.innerHeight) - r.bottom };
}
/** Single option in a {@link Select} list. */
export interface SelectOption<T extends string> {
/** Stored value emitted via `onChange`. */
@@ -144,9 +153,11 @@ export function Select<T extends string>({
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const below = window.innerHeight - rect.bottom;
setOpenUp(below < 288 && rect.top > below);
const { above, below } = menuRoom(
buttonRef.current,
buttonRef.current.getBoundingClientRect()
);
setOpenUp(below < 288 && above > below);
}
listRef.current?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
+2 -7
View File
@@ -74,6 +74,7 @@ import {
} 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 { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
@@ -88,13 +89,6 @@ const NAV_KEYS = [
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
// owned by the brand, not vendored here — a machine with no network shows the
// alt text instead.
const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
const STORAGE_KEY = "sidebar-collapsed";
const STATS_STORAGE_KEY = "sidebar-connection-stats";
const RECENT_EVENTS_CAP = 8;
@@ -496,6 +490,7 @@ export function Sidebar({
<button
type="button"
onClick={onToggle}
data-testid="sidebar-tree-rail"
title={t("lanes:tree.title")}
aria-label={t("lanes:tree.title")}
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
+113 -45
View File
@@ -5,11 +5,15 @@
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
* live tab is opened from its session row. Live status comes from the shared
* lane list (`useWorkLanes`), live panes from `GET /api/run`, re-read whenever a
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
* lane's status or session moves (at most every 3 s) and handed over by the
* 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`).
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
* analyst: EVERY workspace, grouped under its owner (`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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -25,10 +29,11 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useWorkLanes } from "../hooks/useLanes";
import { useLanes } from "../hooks/useLanes";
import { useAuth, usePermissions } from "./AuthGate";
import { useAiTools } from "../hooks/useAiTools";
import { useThrottledValue } from "../hooks/useThrottledValue";
import {
@@ -70,7 +75,12 @@ export function WorkspaceTree() {
const navigate = useNavigate();
const location = useLocation();
const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useWorkLanes();
const { lanes, refresh: refreshLanes } = useLanes();
const { state: auth } = useAuth();
const { seesAll, canEditLane } = usePermissions();
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
// Server mode staff see every user's workspaces, so the list says whose.
const grouped = auth.mode === "server" && seesAll;
const prefs = useProjectTreePrefs();
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [query, setQuery] = useState("");
@@ -132,10 +142,17 @@ export function WorkspaceTree() {
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
[lanes, order, toolFilter]
);
const sorted = useMemo(() => {
const list = sortByStoredOrder(lanes, order).filter(
(l) => !toolFilter || l.tool === toolFilter
);
if (!grouped) return list;
// 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 ?? "")
);
}, [lanes, order, toolFilter, grouped, meId]);
const q = query.trim().toLowerCase();
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
@@ -312,42 +329,90 @@ export function WorkspaceTree() {
)}
<div ref={listRef} className="space-y-px px-1">
{sorted.map((lane, i) => (
<div key={lane.id} className="relative">
{dropAt === i && <DropLine />}
{visible(lane) && (
<WorkspaceRow
lane={lane}
index={i}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
))}
{sorted.map((lane, i) => {
const owner = ownerKey(lane);
// A search opens every group, like it opens every workspace.
const folded = grouped && !q && !!prefs.collapsedOwners[owner];
const readOnly = !canEditLane(lane);
const ownerLabel =
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}`);
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
return (
<div key={lane.id} className="relative">
{groupStart && groupShown && (
<button
type="button"
data-testid={`workspace-owner-${owner}`}
aria-expanded={!folded}
title={t("tree.ownerToggle", { name: ownerLabel })}
onClick={() =>
writeProjectTreePrefs({
collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
})
}
className={`flex w-full items-center gap-1 rounded px-1 py-1 text-left text-[11px] font-medium text-fg-muted hover:text-fg-primary ${i === 0 ? "" : "mt-2"}`}
>
{folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<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>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
</span>
</button>
)}
{dropAt === i && <DropLine />}
{visible(lane) && !folded && (
<WorkspaceRow
lane={lane}
index={i}
readOnly={readOnly ? t("tree.readOnly", { name: ownerLabel }) : undefined}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
// A read-only workspace has no home to open: it only unfolds.
if (readOnly) return setExpanded(lane.id, !expanded(lane.id));
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && !folded && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
readOnly={readOnly}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
);
})}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
@@ -400,6 +465,9 @@ export function WorkspaceTree() {
);
}
/** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
@@ -5,17 +5,19 @@
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
* analyst reads everything but works only in their own workspaces, a
* developer only ever sees (and so changes) their own.
* developer only ever sees (and so changes) their own. The login page wears the
* OS's light/dark; a sign-in swaps in the account's saved appearance.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../i18n";
const me = vi.fn();
const login = vi.fn();
const prefs = vi.fn();
const LANES = [
{ id: 1, owner_id: 5 },
{ id: 2, owner_id: 6 },
@@ -25,6 +27,7 @@ vi.mock("../../lib/api", () => ({
api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
},
}));
@@ -52,6 +55,7 @@ const user = { id: 1, username: "mia", role: "developer" };
beforeEach(() => {
me.mockReset();
login.mockReset();
prefs.mockReset().mockResolvedValue({ prefs: {} });
localStorage.clear();
});
@@ -107,15 +111,15 @@ describe("AuthGate", () => {
render(<AuthGate>app</AuthGate>);
const name = await screen.findByLabelText(/username/i);
const pass = screen.getByLabelText(/password/i);
const pass = screen.getByLabelText(/^password$/i);
await userEvent.type(name, "mia");
await userEvent.type(pass, "nope");
await userEvent.click(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull();
await userEvent.type(pass, "member-pass-1");
await userEvent.click(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText("app")).toBeInTheDocument();
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
@@ -126,6 +130,32 @@ describe("AuthGate", () => {
});
});
describe("AuthGate theme", () => {
it("wears the OS theme while signed out and the account's own once signed in", async () => {
const html = document.documentElement;
// The previous user's choice, left in this browser.
localStorage.setItem("theme", "dark");
localStorage.setItem("agenthub.theme.dark", "darcula");
const mq = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
vi.stubGlobal("matchMedia", () => mq); // the OS is light
me.mockResolvedValue({ mode: "server", user: null });
prefs.mockResolvedValue({ prefs: { theme: "dark", "agenthub.theme.dark": "orca-dark" } });
login.mockResolvedValue({ user });
render(<AuthGate>app</AuthGate>);
await userEvent.type(await screen.findByLabelText(/username/i), "mia");
expect(html.dataset.mode).toBe("light");
expect(html.hasAttribute("data-theme")).toBe(false);
await userEvent.type(screen.getByLabelText(/^password$/i), "member-pass-1");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => expect(html.dataset.theme).toBe("orca-dark"));
expect(html.dataset.mode).toBe("dark");
expect(localStorage.getItem("agenthub.theme.dark")).toBe("orca-dark");
vi.unstubAllGlobals();
});
});
describe("usePermissions / useWorkLanes", () => {
const as = async (state: unknown) => {
cleanup();
@@ -2,7 +2,8 @@
* @file Select.test.tsx
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
* listbox/option roles with aria-selected, aria-activedescendant tracking,
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger.
* Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a
* list that flips up by the room in its split pane, not the window's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -90,4 +91,21 @@ describe("Select (listbox button)", () => {
fireEvent.click(screen.getByText("Apple"));
expect(onChange).toHaveBeenCalledWith("a");
});
it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => {
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
render(
<div data-menu-bounds data-testid="pane">
<Select value="b" onChange={() => {}} options={OPTIONS} label="Fruit" />
</div>
);
// A top pane 0–400 in a 2000px window; the trigger near its bottom.
vi.spyOn(screen.getByTestId("pane"), "getBoundingClientRect").mockReturnValue(rect(0, 400));
const button = screen.getByRole("button", { name: "Fruit" });
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
vi.stubGlobal("innerHeight", 2000);
fireEvent.click(button);
expect(screen.getByRole("listbox").className).toContain("bottom-full");
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,143 @@
/**
* @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),
* 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>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Session } from "../../lib/types";
const ME = { id: 2, username: "ana", role: "analyst" as const };
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
({
id,
title,
cwd: `/w/${id}`,
kind: "adopted",
tool: "claude",
status: "idle",
liveness: "idle",
pipeline_nodes: [],
owner_id: owner,
owner_name: name,
}) as unknown as Lane;
const lanes = [
lane(1, 3, "bob", "Bob app"),
lane(2, null, null, "Old"),
lane(3, ME.id, "ana", "Mine"),
lane(4, 1, "admin", "Admin site"),
];
vi.mock("../AuthGate", () => ({
useAuth: () => ({ state: { mode: "server", user: ME } }),
usePermissions: () => ({
isAdmin: false,
seesAll: true,
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
}),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
sessions: {
list: vi.fn(async () => ({
sessions: [
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
] as Session[],
total: 1,
limit: 6,
offset: 0,
})),
},
run: { list: vi.fn(async () => ({ items: [] })) },
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
},
};
});
import { WorkspaceTree } from "../WorkspaceTree";
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderTree() {
return render(
<MemoryRouter>
<Routes>
<Route
path="*"
element={
<>
<WorkspaceTree />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => localStorage.clear());
describe("WorkspaceTree for an analyst", () => {
it("groups every workspace under its owner: yours first, no owner last", async () => {
renderTree();
await screen.findByTestId("workspace-row-1");
const order = screen
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
.map((el) => el.dataset.testid);
expect(order).toEqual([
"workspace-owner-2",
"workspace-row-3",
"workspace-owner-1",
"workspace-row-4",
"workspace-owner-3",
"workspace-row-1",
"workspace-owner-none",
"workspace-row-2",
]);
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
});
it("folds a group and remembers it", async () => {
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-owner-3"));
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
"3": true,
});
});
it("keeps another user's workspace read only", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
// Yours keeps its controls; theirs has none.
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
// Picking it unfolds it instead of opening a workspace home.
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
const list = await screen.findByTestId("workspace-sessions-1");
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
await user.click(within(list).getByTestId("workspace-session-s-1"));
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
});
});
@@ -5,7 +5,11 @@
* the message stream via MessageList. Combines a WebSocket subscription, a
* visibility-gated polling fallback, and a manual refresh button so the view
* stays caught up even when hooks miss frames or the user is mid-text-only
* turn (no PreToolUse fires until Stop).
* turn (no PreToolUse fires until Stop). The view sticks to its end while
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,12 +83,21 @@ interface ConversationViewProps {
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session runs in a pane: show the step in progress. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
}
export function ConversationView({
sessionId,
initialTranscriptId,
fill = false,
onOpenFile,
live = false,
follow = 0,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
@@ -105,6 +118,7 @@ export function ConversationView({
const lastLineRef = useRef(0);
const firstLineRef = useRef(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const fetchingRef = useRef(false);
// When a fetch is in flight and a new trigger arrives (WS event, poll,
@@ -392,6 +406,30 @@ export function ConversationView({
}
}, [hasMore, loadingHistory, loadHistory]);
// Stick to the end while the reader is there. Scroll events say whether
// they are; a size change — of the content, or of the box itself when a
// hidden pane is shown — never fires one, so it is watched here.
useEffect(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!container || !content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
});
observer.observe(container);
observer.observe(content);
return () => observer.disconnect();
}, []);
// The reader sent a message: show it, and what follows, as it lands.
useEffect(() => {
if (!follow) return;
isAtBottomRef.current = true;
setAwayFromEnd(false);
setShowNewMsg(false);
scrollToBottom();
}, [follow, scrollToBottom]);
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
@@ -450,38 +488,42 @@ export function ConversationView({
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto"
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
<div ref={contentRef}>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">
↑ {t("conversation.scrollForOlder")}
</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</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>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} />
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</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>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
)}
</div>
</div>
{/* Claude Desktop's round "back to the end" button */}
@@ -4,7 +4,9 @@
* 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.
* 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.
*
* 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
@@ -244,6 +246,71 @@ function parseBlocks(src: string): Block[] {
return blocks;
}
/** Opens a file a message names; provided by a view that can show one. */
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
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 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]!);
function UrlLink({ url }: { url: string }) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{url}
</a>
);
}
/** 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);
if (!open) return <>{children}</>;
return (
<button
type="button"
title={path}
onClick={() => open(path)}
className={`${LINK_CLASS} text-left`}
>
{children}
</button>
);
}
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
export function linkify(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let buf = "";
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 hit = url || path;
if (!hit) {
buf += text[i++];
continue;
}
if (buf) out.push(buf);
buf = "";
out.push(
url ? (
<UrlLink key={i} url={url} />
) : (
<PathLink key={i} path={hit}>
{hit}
</PathLink>
)
);
i += hit.length;
}
if (buf) out.push(buf);
return out;
}
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
@@ -267,11 +334,18 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
// Inline code: `...`
const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) {
push(
const code = (
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
{codeM[1]}
</code>
);
push(
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
<PathLink path={codeM[1]!}>{code}</PathLink>
) : (
code
)
);
i += codeM[0].length;
continue;
}
@@ -330,22 +404,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
}
// Auto-link
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
const urlM = rest.match(URL_RE);
if (urlM) {
push(
<a
href={urlM[0]}
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 break-all"
>
{urlM[0]}
</a>
);
push(<UrlLink url={urlM[0]} />);
i += urlM[0].length;
continue;
}
// An absolute path at a word start
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
if (pathM) {
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
i += pathM[0].length;
continue;
}
buf += text[i]!;
i++;
}
@@ -4,7 +4,19 @@
* transcript: alternating user / assistant rows with collapsible thinking
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
* MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page.
* 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: 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
* 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
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -53,7 +65,8 @@
*
* ----------------------------------------------------------------------------- */
import { memo, useState, useMemo } from "react";
import { memo, useEffect, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next";
import i18n from "../../i18n";
import {
@@ -69,6 +82,8 @@ import {
Pencil,
Workflow,
Cog,
FileText,
Loader2,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -112,13 +127,17 @@ const SENDER_STYLES: Record<
},
};
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent } from "./MarkdownContent";
import { fmt, formatModelName } from "../../lib/format";
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
interface MessageListProps {
messages: TranscriptMessage[];
loading: boolean;
/** Opens a file named in the "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -135,6 +154,16 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
return map;
}
/** 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 {
return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
);
}
const usageTitle = (u: NonNullable<TranscriptMessage["usage"]>) =>
`input ${fmt(u.input_tokens || 0)} · cache read ${fmt(u.cache_read_input_tokens || 0)} · cache write ${fmt(u.cache_creation_input_tokens || 0)} · output ${fmt(u.output_tokens || 0)}`;
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */
function isSkillContent(text: string): boolean {
return text.startsWith("Base directory for this skill:");
@@ -390,34 +419,139 @@ type Item =
| { kind: "row"; key: string; msg: TranscriptMessage }
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] };
| { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
const items: 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[] = [];
const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = [];
};
messages.forEach((msg, idx) => {
const key = keys[idx]!;
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
// Purely tool_result: shown inside its call's block.
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") return items.push({ kind: "user", key, msg });
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
if (sender === "user") {
endTurn(key);
return items.push({ kind: "user", key, msg });
}
if (sender !== "assistant") {
// Claude Code's line for the image a prompt attached: the image joins that prompt.
const paths = sender === "system" ? imageSourcePaths(msg) : null;
const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined;
if (paths && prompt?.kind === "user") {
prompt.msg = {
...prompt.msg,
content: [
...prompt.msg.content.map((c) =>
c.type === "text" && c.text
? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") }
: c
),
{ type: "text", text: paths.join("\n") },
],
};
return;
}
return items.push({ kind: "row", key, msg });
}
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) {
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
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.text?.trim()) {
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
}
});
});
endTurn(keys[keys.length - 1] ?? "end");
return items;
}
export function MessageList({ messages, loading }: MessageListProps) {
/** 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({
blocks,
toolResultMap,
onOpenFile,
}: {
blocks: TranscriptContent[];
toolResultMap: Map<string, TranscriptContent>;
onOpenFile?: (path: string) => void;
}) {
const { t } = useTranslation("sessions");
// `counted`: a result carried its +/- lines (an older server sends none).
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
for (const b of blocks) {
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
if (files.size === 0) return null;
let added = 0;
let removed = 0;
let counted = false;
files.forEach((f) => {
added += f.added;
removed += f.removed;
counted ||= f.counted;
});
const counts = (a: number, r: number, show = counted) =>
show && (
<span className="flex-shrink-0 font-mono text-xs">
<span className="text-status-success">+{a}</span>{" "}
<span className="text-status-danger">−{r}</span>
</span>
);
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">
<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)}
</div>
{[...files].map(([p, f]) => (
<button
key={p}
type="button"
title={p}
disabled={!onOpenFile}
onClick={() => onOpenFile?.(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" />
<span className="min-w-0 flex-1 truncate">{p.split("/").pop()}</span>
{counts(f.added, f.removed, f.counted)}
{onOpenFile && <ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />}
</button>
))}
</div>
);
}
export function MessageList({ messages, loading, onOpenFile, live = false }: 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 items = useMemo(() => chatItems(messages, keys), [messages, keys]);
@@ -434,33 +568,175 @@ export function MessageList({ messages, loading }: MessageListProps) {
}
return (
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
{items.map((item) => {
switch (item.kind) {
case "event":
return (
<SessionEventRow
key={item.key}
title={item.msg.title}
timestamp={item.msg.timestamp}
/>
);
case "row":
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
case "user":
return <UserBubble key={item.key} msg={item.msg} />;
case "text":
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
}
})}
<OpenFileContext.Provider value={onOpenFile}>
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
{items.map((item) => {
switch (item.kind) {
case "event":
return (
<SessionEventRow
key={item.key}
title={item.msg.title}
timestamp={item.msg.timestamp}
/>
);
case "row":
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
case "user":
return <UserBubble key={item.key} msg={item.msg} />;
case "text":
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return (
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
);
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
/>
);
}
})}
{pending && <RunningStep block={pending.block} since={pending.since} />}
</div>
</OpenFileContext.Provider>
);
}
// No line for this long and no call running: the turn died with no end
// 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. */
export function workingOn(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): { block: TranscriptContent | null; since: string | null } | null {
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;
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;
}
// 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;
}
return null;
}
/** Claude Desktop's live line: the step running now and for how long. */
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => 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(" ");
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())}
</span>
)}
</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
// 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;
// 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[] } {
const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
images.push(name);
return "";
});
return {
text: images.length
? rest
.replace(/\[Image #\d+\]/g, "")
.replace(/[ \t]+$/gm, "")
.trim()
: text,
images,
};
}
/** The drop paths of a meta message made only of `[Image: source: <drop>]` lines. */
function imageSourcePaths(msg: TranscriptMessage): string[] | null {
const lines = msg.content.flatMap((c) => (c.type === "text" ? (c.text ?? "").split("\n") : []));
const paths = lines.map((l) => IMAGE_SOURCE.exec(l.trim())?.[1] ?? "");
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
}
function DropImage({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
const src = `/api/run/drop/${encodeURIComponent(name)}`;
// Drops are pruned after a day: the name is all that is left to show.
if (gone)
return (
<span className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-xs text-fg-muted">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{name.replace(/^[0-9a-f]{16}-?/, "") || name}</span>
</span>
);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="block overflow-hidden rounded-xl border border-border bg-surface-2 hover:border-accent/50"
>
<img
src={src}
alt={name}
loading="lazy"
onError={() => setGone(true)}
className="max-h-48 max-w-64 object-contain"
/>
</button>
{open && <ImageLightbox src={src} alt={name} onClose={() => setOpen(false)} />}
</>
);
}
/** The human's turn: a bubble on the right, its time on hover. A long one (a
* paste, a compaction summary) shows its first lines until opened. */
const LONG_BUBBLE = 800;
@@ -468,35 +744,54 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
const { t } = useTranslation("sessions");
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
const [open, setOpen] = useState(false);
const blocks = useMemo(
() =>
msg.content.map((block) =>
block.type === "text" && block.text ? splitDropImages(block.text) : null
),
[msg.content]
);
const images = blocks.flatMap((b) => b?.images ?? []);
const hasText = blocks.some((b) => b?.text);
return (
<div
data-testid="chat-user"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
<div
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
long && !open
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
: ""
}`}
>
{msg.content.map((block, b) => {
if (block.type !== "text" || !block.text) return null;
// Plain typing reads as typed; anything structured keeps its rendering.
const plain =
!hasTuiTags(block.text) &&
!isSkillContent(block.text) &&
!isTaskNotification(block.text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{stripAnsi(block.text).trim()}
</p>
) : (
renderTextBlock(block.text, b)
);
})}
</div>
{images.length > 0 && (
<div
data-testid="chat-user-images"
className="flex max-w-[85%] flex-wrap justify-end gap-2"
>
{images.map((name) => (
<DropImage key={name} name={name} />
))}
</div>
)}
{hasText && (
<div
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
long && !open
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
: ""
}`}
>
{blocks.map((block, b) => {
const text = block?.text;
if (!text) return null;
// Plain typing reads as typed; anything structured keeps its rendering.
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())}
</p>
) : (
renderTextBlock(text, b)
);
})}
</div>
)}
{long && (
<button
type="button"
@@ -540,8 +835,8 @@ const AssistantText = memo(function AssistantText({
>
{msg.model && <span>{formatModelName(msg.model)}</span>}
{msg.usage && (
<span>
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
<span title={usageTitle(msg.usage)}>
↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
</span>
)}
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
@@ -592,6 +887,8 @@ const WORK_KIND: Record<string, string> = {
};
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"];
export function workLabel(blocks: TranscriptContent[]): string {
const counts = new Map<string, number>();
for (const b of blocks) {
@@ -600,7 +897,9 @@ export function workLabel(blocks: TranscriptContent[]): string {
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
if (!counts.size) return i18n.t("sessions:conversation.thinking");
// Claude Desktop's order: commands, edits, reads, … and other tools last.
const text = [...counts]
.sort(([a], [b]) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b))
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
.join(", ");
return text.charAt(0).toUpperCase() + text.slice(1);
@@ -751,8 +1050,11 @@ const MessageRow = memo(function MessageRow({
</span>
)}
{msg.usage && (
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
<span
title={usageTitle(msg.usage)}
className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1"
>
<span className="text-status-success/70">↓ {fmt(promptTokens(msg.usage))}</span>
<span className="text-fg-muted">·</span>
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
</span>
@@ -0,0 +1,93 @@
/**
* @file ConversationView.follow.test.tsx
* @description A chat sticks to its end: when it grows or is shown again
* after being hidden (a size change, which fires no scroll event) it scrolls
* to the newest while the reader is at the end, leaves a reader who scrolled
* up alone, and a send (`follow`) brings it back to the end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
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 resize: (() => void) | null = null;
const original = globalThis.ResizeObserver;
beforeEach(() => {
resize = null;
// @ts-expect-error test stub
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
resize = cb;
}
observe() {}
disconnect() {}
};
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
globalThis.ResizeObserver = original;
vi.restoreAllMocks();
});
/** The scroll box, with the layout jsdom does not compute. */
function box(height: number) {
const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement;
Object.defineProperty(el, "scrollHeight", { configurable: true, value: height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 });
Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 });
return el;
}
describe("ConversationView sticks to its end", () => {
it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => {
const view = render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const el = box(1000);
// Shown again after display:none, or taller: only a resize says so.
act(() => resize?.());
expect(el.scrollTop).toBe(1000);
// The reader scrolls up: growing no longer pulls them down.
el.scrollTop = 100;
fireEvent.scroll(el);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 });
act(() => resize?.());
expect(el.scrollTop).toBe(100);
// They send a message: back to the end, and it sticks again.
view.rerender(<ConversationView sessionId="s1" fill live follow={1} />);
expect(el.scrollTop).toBe(1400);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 });
act(() => resize?.());
expect(el.scrollTop).toBe(1600);
});
});
@@ -0,0 +1,70 @@
/**
* @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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-09T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const call = (id: string, name: string, file_path: string): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id, name, input: { file_path } }],
}) as TranscriptMessage;
const result = (id: string, diff?: { added: number; removed: number }, is_error = false) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "ok", is_error, ...(diff ? { diff } : {}) }],
}) as TranscriptMessage;
describe("the Edited N files card", () => {
it("sums each file's lines over the turn and opens a file on click", () => {
const onOpenFile = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
messages={[
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"),
result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }),
call("4", "Edit", "/w/c.ts"),
result("4", undefined, true),
user("thanks"),
call("5", "Read", "/w/a.ts"),
result("5"),
]}
/>
);
const cards = screen.getAllByTestId("edited-files");
expect(cards).toHaveLength(1); // the second turn only read
const card = cards[0]!;
expect(within(card).getByText("Edited 2 files")).toBeTruthy();
expect(within(card).getByText("+15")).toBeTruthy();
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
});
});
@@ -0,0 +1,113 @@
/**
* @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
* 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>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, splitDropImages } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const SHOT = "366d0a87d12176a2-image.png";
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] });
// Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
]);
// The drop dir since the data-dir move.
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
text: "fix this",
images: [SHOT],
});
// 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: [] });
}
});
it("shows the image instead of its path, full size on click", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `look at this /tmp/agenthub-drops-1001/${SHOT}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("look at this");
expect(bubble).not.toHaveTextContent("agenthub-drops");
const img = screen.getByRole("img", { name: SHOT });
expect(img.getAttribute("src")).toBe(`/api/run/drop/${SHOT}`);
fireEvent.click(img);
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
});
it("folds Claude Code's [Image: source:] meta line into the turn", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T09:43:00.000Z",
content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
},
{
type: "user",
sender: "system",
timestamp: "2026-10-09T09:43:11.000Z",
content: [
{ type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
expect(screen.queryByText("System")).toBeNull();
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
expect(bubble).not.toHaveTextContent("[Image #1]");
expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
`/api/run/drop/${SHOT}`
);
});
it("makes URLs and absolute paths clickable", () => {
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T09:43:00.000Z",
content: [
{
type: "text",
text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
},
],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
expect(link.getAttribute("target")).toBe("_blank");
fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
expect(opened).toEqual(["/home/me/repo/server/x.js"]);
});
});
@@ -9,7 +9,7 @@
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, workLabel } from "../MessageList";
import { MessageList, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -76,5 +76,132 @@ describe("MessageList — sender attribution", () => {
"Ran 2 commands, edited a file, read a file"
);
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
// Desktop's fixed order, whatever ran first: other tools always last.
expect(workLabel([use("mcp__x__y"), use("Read"), use("Bash")])).toBe(
"Ran 1 command, read a file, used a tool"
);
});
it("reads a thinking block's text as narration, between two groups", () => {
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
});
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
const say = (text: string) =>
msg({ type: "assistant", sender: "assistant", content: [{ type: "thinking", text }] });
render(
<MessageList
loading={false}
messages={[call("a"), result("a"), say("Found an issue."), say(""), call("b"), result("b")]}
/>
);
expect(screen.getAllByTestId("chat-tools")).toHaveLength(2);
expect(screen.getByText("Found an issue.")).toBeTruthy();
});
it("shows the whole prompt as ↓, not just its uncached tail", () => {
render(
<MessageList
loading={false}
messages={[
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: 2,
output_tokens: 40,
cache_read_input_tokens: 120000,
cache_creation_input_tokens: 3000,
},
}),
]}
/>
);
expect(screen.getByText(/↓ 123/)).toBeTruthy();
});
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: "assistant",
sender: "assistant",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [
{
type: "tool_use",
id: "t1",
name: "Bash",
input: { command: "npm test", description: "Run the full client suite" },
},
],
}),
];
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/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
loading={false}
live
messages={[
...messages,
msg({ content: [{ type: "tool_result", id: "t1", output: "ok" }] }),
]}
/>
);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
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";
const prompt = msg({ type: "user", sender: "user", timestamp: at });
const call = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }],
});
const result = msg({
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id: "t1", output: "ok" }],
});
const reply = (stop_reason?: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason,
content: [{ type: "text", text: "ok" }],
});
const results = (m: ReturnType<typeof msg>[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
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
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({
type: "user",
sender: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
});
expect(state([prompt, call, result, stopped])).toBeNull();
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
});
+29 -5
View File
@@ -10,7 +10,8 @@
* (Claude Code or Codex) — this choice is immutable after creation. 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).
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
* which a click into the field opens too.
* The worktree lane returned is `status: "provisioning"`:
* the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the
@@ -29,6 +30,7 @@ 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
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -111,7 +113,14 @@ export function AddLaneModal({
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null);
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
// The account's own pick (first-run setup), while it is installed and on.
const ownTool = readDefaultTool();
const defaultTool =
(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
@@ -319,6 +328,7 @@ 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
@@ -355,6 +365,7 @@ export function AddLaneModal({
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-tour="add-lane"
>
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
@@ -449,8 +460,15 @@ export function AddLaneModal({
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<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();
}}
>
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
@@ -458,7 +476,12 @@ export function AddLaneModal({
suggestions={cwdSuggestions}
/>
</div>
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
<PathPickButton
start={sourceRepo}
onPick={setSourceRepo}
showLabel
buttonRef={browseRef}
/>
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
@@ -695,6 +718,7 @@ export function AddLaneModal({
type="button"
onClick={done ? handleCancel : submit}
disabled={busy || disabled}
data-tour="add-lane-create"
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
@@ -66,6 +66,7 @@ export function FolderBrowseModal({
role="dialog"
aria-modal="true"
aria-label={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}>
@@ -17,12 +17,16 @@
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
* mode switcher's Files button; at full width the sidebar)
* mode switcher's Files button; at full width the sidebar);
* a row dragged onto a prompt box attaches it as `@path`
*
* and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
* `commit` (one commit, reached from history).
*
* A file can also be opened from outside the panel (`openRequest`: a chat's
* "Edited N files" card), the same way a click in the tree opens it.
*
* Only the overview polls. Review, file, history and browse are a deliberate
* read of a frozen moment — re-fetching under the reader's scroll position is
* worse than a slightly stale view, and those are the expensive shapes.
@@ -121,6 +125,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
import type {
LaneChangeFile,
@@ -277,6 +282,8 @@ function TreeNodes({
data-tree-ignored={e.ignored || undefined}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
draggable
onDragStart={(ev) => dragProjectPath(ev, e.dir ? `${e.path}/` : e.path)}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
aria-current={active === e.path ? "true" : undefined}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
@@ -792,18 +799,25 @@ function GraphCell({ row, width }: { row: GraphRow; width: number }) {
export function LaneChangesPanel({
laneId,
session,
source,
onNewLane,
full = false,
wide = full,
onDirtyChange,
openRequest = null,
}: {
laneId: 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). */
onDirtyChange?: (dirty: boolean) => void;
/** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
session?: string;
/** Show and act on that extra folder's repo instead (`lane_sources` id);
* wins over `session`, and is keyed on like it. */
source?: number;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
@@ -815,11 +829,11 @@ export function LaneChangesPanel({
const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId, session);
const [git, refreshGit] = useLaneGitFacts(laneId, session, source);
// A merge, cherry-pick or revert is open: no new one starts from history.
const opOpen = !!(git?.available && git.merging);
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session);
const target = gitTarget(laneId, session, source);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback(
(text: string) => {
@@ -1232,6 +1246,11 @@ export function LaneChangesPanel({
const show = (path: string) => {
void confirmDrop().then((ok) => ok && showNow(path));
};
// A file named from outside the panel (a chat's "Edited N files" card).
useEffect(() => {
if (openRequest) show(openRequest.path);
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per request
}, [openRequest?.nonce]);
// A file's history (Show History): the commits that touched it.
const showHistory = (path: string) => {
void confirmDrop().then((ok) => {
@@ -2053,6 +2072,8 @@ export function LaneChangesPanel({
<button
type="button"
onClick={() => void openWhole(p)}
draggable
onDragStart={(ev) => dragProjectPath(ev, p)}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
@@ -2105,6 +2126,7 @@ export function LaneChangesPanel({
<LaneGitHeader
laneId={laneId}
session={session}
source={source}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
@@ -2487,7 +2509,9 @@ export function LaneChangesPanel({
) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
<p className="text-[11px] text-fg-muted">
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
</p>
) : files.length === 0 && mode !== "file" ? (
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
) : mode === "overview" ? (
@@ -2678,6 +2702,7 @@ export function LaneChangesPanel({
<LaneConflictView
laneId={laneId}
session={session}
source={source}
path={openFile}
onResolved={() => {
refreshGit();
@@ -38,6 +38,7 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
export function LaneConflictView({
laneId,
session,
source,
path,
onResolved,
onEdit,
@@ -45,6 +46,8 @@ export function LaneConflictView({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
@@ -65,7 +68,7 @@ export function LaneConflictView({
setChoices([]);
setError(null);
api.lanes
.gitFile(gitTarget(laneId, session), path, "work")
.gitFile(gitTarget(laneId, session, source), path, "work")
.then((r) => {
if (!live) return;
setFile(
@@ -78,7 +81,7 @@ export function LaneConflictView({
return () => {
live = false;
};
}, [laneId, session, path]);
}, [laneId, session, source, path]);
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
@@ -102,15 +105,18 @@ export function LaneConflictView({
run(async () => {
if (!parts || !file?.sha) return;
await api.lanes.gitSaveFile(
gitTarget(laneId, session),
gitTarget(laneId, session, source),
path,
applyChoices(parts, choices).join("\n"),
file.sha
);
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", {
path,
side: "done",
});
});
const takeAll = (side: "ours" | "theirs") =>
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side }));
const choose = (i: number, c: ConflictChoice | undefined) =>
setChoices((prev) => {
+6 -10
View File
@@ -45,14 +45,7 @@ import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
| "fetch"
| "pull"
| "push"
| "switch"
| "branch-create"
| "branch-delete"
| "merge"
| "publish";
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
@@ -95,6 +88,7 @@ function Btn({
export function LaneGitBar({
laneId,
session,
source,
git,
onChanged,
onNewLane,
@@ -103,6 +97,8 @@ export function LaneGitBar({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
@@ -165,7 +161,7 @@ export function LaneGitBar({
setBusy(action);
setError(null);
try {
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
@@ -218,7 +214,7 @@ export function LaneGitBar({
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
+12 -4
View File
@@ -36,7 +36,9 @@ const GIT_REFRESH_MS = 30_000;
export function useLaneGitFacts(
laneId: number,
/** Read that session's own worktree instead (`?session=`). */
session?: string
session?: string,
/** Read that extra folder's repo instead (`?source=`). */
source?: number
): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
@@ -54,7 +56,7 @@ export function useLaneGitFacts(
let alive = true;
const read = () => {
api.lanes
.git(gitTarget(laneId, session))
.git(gitTarget(laneId, session, source))
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
@@ -73,7 +75,7 @@ export function useLaneGitFacts(
alive = false;
clearInterval(timer);
};
}, [laneId, session, nonce]);
}, [laneId, session, source, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
@@ -322,20 +324,23 @@ export function LaneGitCounts({
export function LaneGitRailCounts({
laneId,
session,
source,
onShowAll,
}: {
laneId: number;
session?: string;
source?: number;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
const [git] = useLaneGitFacts(laneId, session);
const [git] = useLaneGitFacts(laneId, session, source);
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
}
export function LaneGitHeader({
laneId,
session,
source,
git,
onChanged,
onNewLane,
@@ -343,6 +348,8 @@ export function LaneGitHeader({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
@@ -374,6 +381,7 @@ export function LaneGitHeader({
<LaneGitBar
laneId={laneId}
session={session}
source={source}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
@@ -173,6 +173,7 @@ export function SessionReplay({
rows={2}
slash={tool === "claude"}
fileCwd={session?.cwd || lane.cwd}
lane={lane.id}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
+41 -3
View File
@@ -5,7 +5,8 @@
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
* the box, like Claude Desktop's: the `+` menu (`ChatAddMenu`: upload, or
* attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's
* by default; its last row, **Open terminal**, is no AI — it starts a plain
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
@@ -59,6 +60,8 @@ 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";
@@ -85,6 +88,7 @@ export {
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
import { readDefaultMode } from "../../lib/sessionDefaults";
function fmtBytes(n: number | null): string {
if (!n) return "";
@@ -114,12 +118,19 @@ function HomeBody({
lane,
onMenuAction,
onRenamed,
onSourcesChange,
sourcesVersion = 0,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
/** 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 Changes panel's
* Close repository): the Folder list re-reads. */
sourcesVersion?: number;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -148,6 +159,7 @@ function HomeBody({
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]);
const pickFiles = useRef<(() => void) | null>(null);
const [proxy, setProxy] = useSessionProxy();
const proxies = useAiProxies();
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
@@ -173,7 +185,8 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
const [mode, setMode] = useState<PermissionMode>("auto");
// The account's own default (first-run setup) — never bypass.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
@@ -305,10 +318,25 @@ function HomeBody({
};
}, [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 {
setView(await fn());
const next = await fn();
setView(next);
onSourcesChange?.(next);
return true;
} catch (err) {
setError(message(err));
@@ -529,12 +557,22 @@ function HomeBody({
rows={3}
slash={claude}
fileCwd={lane.cwd}
lane={lane.id}
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
pickFiles={pickFiles}
bare
/>
<div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={lane.cwd}
laneId={lane.id}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
<div data-testid="workspace-home-tool">
<Select<string>
variant="ghost"
+18 -11
View File
@@ -8,13 +8,14 @@
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
* dot's meaning is screen-reader text, and an idle dot is a ring.
* dot's meaning is screen-reader text, and an idle dot is a ring. A `readOnly`
* row (another user's workspace, for an analyst) has no menu, rename or `+`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import { ChevronDown, ChevronRight, Eye, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types";
import { isSubmitEnter } from "../../lib/keys";
@@ -49,6 +50,7 @@ export function workspaceName(lane: Lane): string {
export function WorkspaceRow({
lane,
index,
readOnly,
selected,
expanded,
renaming,
@@ -63,6 +65,9 @@ export function WorkspaceRow({
lane: Lane;
/** Position in the list — the unit both reorder paths speak in. */
index: number;
/** Set (to its tooltip) for a workspace this user may only read: no menu,
* no rename, no new session. */
readOnly?: string;
selected: boolean;
expanded: boolean;
renaming: boolean;
@@ -82,7 +87,12 @@ export function WorkspaceRow({
const openMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onMenu(e.clientX, e.clientY);
if (!readOnly) onMenu(e.clientX, e.clientY);
};
const startRename = () => {
if (readOnly) return;
setDraft(lane.title || "");
onRenameStart();
};
const Chevron = expanded ? ChevronDown : ChevronRight;
const hoverBtn =
@@ -140,22 +150,18 @@ export function WorkspaceRow({
aria-current={selected ? "true" : undefined}
// A double-click's second click renames instead of opening again.
onClick={(e) => e.detail < 2 && onSelect()}
onDoubleClick={() => {
setDraft(lane.title || "");
onRenameStart();
}}
onDoubleClick={startRename}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
setDraft(lane.title || "");
onRenameStart();
startRename();
return;
}
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}}
title={`${lane.cwd}\n${t("row.hint")}`}
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
@@ -163,7 +169,8 @@ export function WorkspaceRow({
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
</button>
)}
{!renaming && (
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
{!renaming && !readOnly && (
<>
<button
type="button"
@@ -24,7 +24,9 @@
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text.
* dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -220,6 +222,7 @@ export function WorkspaceSessionList({
selectedSessionId = null,
onMatches,
hidden = false,
readOnly = false,
}: {
lane: Lane;
/** Every live dashboard pane, from `GET /api/run`. */
@@ -235,6 +238,8 @@ export function WorkspaceSessionList({
onMatches?: (count: number) => void;
/** Mounted only to load and count, during a search that hides the row. */
hidden?: boolean;
/** A workspace this user may only read. */
readOnly?: boolean;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -315,7 +320,7 @@ export function WorkspaceSessionList({
};
}, [orphanIds]);
const fresh =
sessions === null || q
sessions === null || q || readOnly
? []
: [
...laneRuns.filter(
@@ -492,6 +497,7 @@ export function WorkspaceSessionList({
// A double-click's second click renames; it must not
// navigate (a second history entry) on top of the first.
if (e.detail > 1) return;
if (readOnly) return navigate(`/sessions/${encodeURIComponent(s.id)}`);
navigate(
run
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
@@ -501,19 +507,19 @@ export function WorkspaceSessionList({
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
);
}}
onDoubleClick={() => startRename(s)}
onDoubleClick={() => !readOnly && startRename(s)}
onKeyDown={(e) => {
if (e.key === "F2") {
if (e.key === "F2" && !readOnly) {
e.preventDefault();
startRename(s);
}
}}
onContextMenu={(e) => sessionCtx.open(e, s)}
onContextMenu={readOnly ? undefined : (e) => sessionCtx.open(e, s)}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s)
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
}${readOnly ? "" : `\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
@@ -539,21 +545,23 @@ export function WorkspaceSessionList({
</span>
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
/>
</button>
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => void archive(s)}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
// Invisible, not `hidden`: a keyboard user must be able to
// Tab onto it, and it shows when focused.
className="absolute right-1 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"
>
<Archive className="h-3.5 w-3.5" />
</button>
{!readOnly && (
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => void archive(s)}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
// Invisible, not `hidden`: a keyboard user must be able to
// Tab onto it, and it shows when focused.
className="absolute right-1 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"
>
<Archive className="h-3.5 w-3.5" />
</button>
)}
</li>
);
})}
@@ -582,6 +582,19 @@ 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" });
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");
});
it("falls back to the in-app browser when no native picker is available", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
@@ -255,6 +255,36 @@ describe("LaneChangesPanel", () => {
expect(view).toHaveTextContent("keep");
});
it("opens a file asked for from outside (a chat's Edited files card), once per request", async () => {
const view = render(
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
);
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
vi.mocked(api.lanes.changes).mockClear();
// The same request again changes nothing; a new nonce opens the next one.
view.rerender(
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
);
view.rerender(<LaneChangesPanel laneId={7} openRequest={{ path: "src/main.ts", nonce: 2 }} />);
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "src/main.ts",
context: 3,
})
);
expect(api.lanes.changes).not.toHaveBeenCalledWith(
7,
expect.objectContaining({ path: "server/lib/git-ops.js" })
);
});
it("asks for every file's hunks in review mode", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -0,0 +1,239 @@
/**
* @file OnboardingTour.tsx
* @description The step-by-step spotlight tour after the first-run setup
* (docs/plans/onboarding): it dims the page around ONE real control and says
* what to do with it — Add workspace → the folder → Add → the prompt box →
* the live session → done. The overlay never takes a click (the user works
* the real UI); a step moves on when what it asked for has happened (the
* dialog opened, the workspace exists, the session started), and steps back
* when the user backed out (the dialog closed with nothing made). Targets are
* existing `data-testid`s plus `data-tour` marks in AddLaneModal and
* FolderBrowseModal. The
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the
* account and survives a reload; `done` ends it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
const KEY = "agenthub.tour";
const EVENT = "agenthub-tour";
const POLL_MS = 250;
type StepId = "add" | "folder" | "create" | "prompt" | "session" | "finish";
const ORDER: StepId[] = ["add", "folder", "create", "prompt", "session", "finish"];
const $ = (sel: string) => document.querySelector(sel);
/** An element that is on screen (a console collapsed with CSS has no box). */
function shown(sel: string): Element | null {
const el = $(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 ? el : null;
}
const modalOpen = () => !!$('[data-tour="add-lane"]');
const composer = () => shown('[data-testid="workspace-home-composer"]');
const sessionLive = () =>
!!new URLSearchParams(window.location.search).get("run") && !!shown('[data-testid="pane-grid"]');
/** Where each step points, and what moves it on (or back). */
const STEPS: Record<
StepId,
{ target: () => Element | null; next?: () => StepId | null; info?: boolean }
> = {
add: {
// The sidebar's +; folded to a rail, its tree icon (which unfolds it).
target: () =>
shown('[data-testid="workspace-tree-add"]') || shown('[data-testid="sidebar-tree-rail"]'),
next: () => (modalOpen() ? "folder" : null),
},
folder: {
// The folder browser while it is open: it sits over the field.
target: () => $('[data-tour="folder-browse"]') || $('[data-tour="add-lane-folder"]'),
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
info: true,
},
create: {
target: () => $('[data-tour="add-lane-create"]'),
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
},
prompt: {
target: composer,
next: () => (sessionLive() ? "session" : null),
},
session: { target: () => shown('[data-testid="pane-grid"]'), info: true },
finish: { target: () => null, info: true },
};
function read(): StepId | null {
try {
const v = localStorage.getItem(KEY);
return (ORDER as string[]).includes(v ?? "") ? (v as StepId) : null;
} catch {
return null;
}
}
function write(v: StepId | "done") {
try {
localStorage.setItem(KEY, v);
} catch {
/* storage blocked: the tour just ends with the page */
}
window.dispatchEvent(new Event(EVENT));
}
/** Begin the tour at its first step (the setup's last button). */
export const startTour = () => write("add");
/** Mounted once in the layout; renders nothing unless a tour is running. */
export function OnboardingTour() {
const { t } = useTranslation("onboarding");
const navigate = useNavigate();
const location = useLocation();
const [step, setStep] = useState<StepId | null>(read);
const [rect, setRect] = useState<DOMRect | null>(null);
// The dialog the target sits in, if any: the card goes beside the dialog.
const [box, setBox] = useState<DOMRect | null>(null);
useEffect(() => {
const sync = () => setStep(read());
window.addEventListener(EVENT, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(EVENT, sync);
window.removeEventListener("storage", sync);
};
}, []);
useEffect(() => {
if (!step) return;
const tick = () => {
const def = STEPS[step];
const moved = def.next?.();
if (moved && moved !== step) return write(moved);
const el = def.target();
let r = el ? el.getBoundingClientRect() : null;
// The folder field's suggestions hang below it: light them too.
const list = el?.querySelector("[data-cwd-suggestions]")?.getBoundingClientRect();
if (r && list) {
const top = Math.min(r.top, list.top);
const left = Math.min(r.left, list.left);
r = new DOMRect(
left,
top,
Math.max(r.right, list.right) - left,
Math.max(r.bottom, list.bottom) - top
);
}
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
// Only when it moved: a new DOMRect every tick would re-render forever.
setRect((prev) =>
prev &&
r &&
prev.x === r.x &&
prev.y === r.y &&
prev.width === r.width &&
prev.height === r.height
? prev
: r
);
};
tick();
const id = window.setInterval(tick, POLL_MS);
return () => window.clearInterval(id);
}, [step, location]);
if (!step) return null;
const n = ORDER.indexOf(step) + 1;
const end = () => write("done");
const advance = () => {
if (step === "folder") return write("create");
if (step === "session") return write("finish");
end();
};
const lost = !rect && step !== "finish";
// Beside a narrow target (a field in a dialog: below it, the card would
// cover the rest of the dialog), else under it when there is room, else
// above; centred when there is no target (the finish, or the page moved).
const W = 320;
let style: React.CSSProperties = { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
if (rect) {
const right = (box ?? rect).right;
const top = Math.min(Math.max(12, rect.top), window.innerHeight - 220);
const left = Math.min(Math.max(12, rect.left), window.innerWidth - W - 12);
if (rect.width < window.innerWidth / 2 && right + W + 24 < window.innerWidth) {
style = { left: right + 16, top };
} else if (rect.bottom + 180 < window.innerHeight) {
style = { left, top: rect.bottom + 12 };
} else {
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
}
}
return createPortal(
<div className="pointer-events-none fixed inset-0 z-[70]" data-testid="onboarding-tour">
{rect ? (
<div
aria-hidden
className="absolute rounded-lg ring-2 ring-accent transition-all duration-200"
style={{
left: rect.left - 6,
top: rect.top - 6,
width: rect.width + 12,
height: rect.height + 12,
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.55)",
}}
/>
) : (
step === "finish" && <div aria-hidden className="absolute inset-0 bg-black/55" />
)}
<div
role="dialog"
aria-live="polite"
aria-label={t(`tour.${step}.title`)}
className="pointer-events-auto absolute w-80 space-y-2 rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
style={style}
>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
{t("tour.progress", { n, total: ORDER.length })}
</p>
<p className="text-sm font-semibold text-fg-primary">{t(`tour.${step}.title`)}</p>
<p className="text-xs leading-relaxed text-fg-secondary">{t(`tour.${step}.body`)}</p>
<div className="flex items-center gap-2 pt-1">
{step !== "finish" && (
<button type="button" onClick={end} className="btn-ghost px-0 text-xs">
{t("tour.skip")}
</button>
)}
<span className="flex-1" />
{lost && !location.pathname.startsWith("/workspace") && (
<button
type="button"
onClick={() => navigate("/workspace")}
className="btn-ghost border border-border text-xs"
>
{t("tour.goWorkspaces")}
</button>
)}
{STEPS[step].info && (
<button
type="button"
onClick={advance}
data-testid="onboarding-tour-next"
className="btn-primary text-xs"
>
{step === "finish" ? t("tour.done") : t("tour.next")}
</button>
)}
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,543 @@
/**
* @file OnboardingWizard.tsx
* @description The first-run setup, once per ACCOUNT (docs/plans/onboarding;
* modelled on Orca's welcome): 1 the default agent — the AI tools detected on
* this machine, the rest behind "show more" — and the permission mode a new
* session starts in (never bypass: that stays per session); 2 the look —
* System / Dark / Light, a theme for each mode, language and terminal font;
* 3 the corner pet (`PetSettings`); 4 notifications — permission, sound,
* a test. Every choice is the same localStorage write Settings makes, so it
* applies at once and is synced to the account (`lib/userPrefs.ts`). Shown
* when the account has not been through it and has no workspace yet (one that
* has is marked done silently), or when Settings asks for it again. The last
* step hands over to the tour (`OnboardingTour`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Check, ChevronLeft, Monitor, Moon, Play, Sun, Zap } from "lucide-react";
import { api } from "../../lib/api";
import { useDialog } from "../../hooks/useDialog";
import { useAiTools } from "../../hooks/useAiTools";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { AiToolIcon } from "../AiToolIcon";
import { Switch } from "../Switch";
import { SettingsSegmented } from "../settings/SettingsRow";
import { PetSettings } from "../settings/PetSettings";
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
import {
DEFAULT_MODES,
readDefaultMode,
readDefaultTool,
writeDefaultMode,
writeDefaultTool,
type DefaultMode,
} from "../../lib/sessionDefaults";
import {
TERMINAL_FONTS,
readTerminalFont,
writeTerminalFont,
type TerminalFont,
} from "../../lib/terminalFont";
import {
NOTIF_SOUNDS,
loadNotifPrefs,
saveNotifPrefs,
type NotifPrefs,
type NotifSound,
} from "../../lib/notifPrefs";
import { notify, playSound } from "../../lib/notify";
import { subscribeToPush } from "../../lib/push";
import { startTour } from "./OnboardingTour";
const STEPS = ["agent", "look", "pet", "notify"] as const;
type Step = (typeof STEPS)[number];
/** Mounted once in the layout: decides whether this account sees the setup. */
export function OnboardingWizard() {
const { loaded, onboardedAt, forced } = useOnboarding();
const [show, setShow] = useState(false);
useEffect(() => {
if (!loaded || onboardedAt) return setShow(false);
if (forced) return setShow(true);
let alive = true;
api.lanes
.list()
.then((r) => {
if (!alive) return;
// An account already at work never needed a welcome.
if (r.lanes.length) void markOnboarded();
else setShow(true);
})
.catch(() => {
/* cannot tell: ask again next load rather than interrupt */
});
return () => {
alive = false;
};
}, [loaded, onboardedAt, forced]);
return show ? <Wizard /> : null;
}
function Wizard() {
const { t } = useTranslation("onboarding");
const navigate = useNavigate();
const [index, setIndex] = useState(0);
const step: Step = STEPS[index] ?? "agent";
const last = index === STEPS.length - 1;
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef);
const skip = () => void markOnboarded();
const next = () => {
if (!last) return setIndex(index + 1);
void markOnboarded();
startTour();
navigate("/");
};
// Ctrl/⌘+Enter continues, like Orca.
const nextRef = useRef(next);
nextRef.current = next;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
nextRef.current();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<div className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
data-testid="onboarding-wizard"
className="flex h-[min(820px,92vh)] w-full max-w-4xl flex-col rounded-2xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
>
<div className="space-y-5 px-8 pt-7">
<div className="flex items-center gap-2.5">
<img src="/favicon.svg" alt="" className="h-7 w-7" />
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
</div>
<div className="flex items-center gap-3" aria-hidden>
{STEPS.map((s, i) => (
<span
key={s}
className={`h-1 w-9 rounded-full ${i <= index ? "bg-fg-primary" : "bg-surface-4"}`}
/>
))}
<span className="text-xs text-fg-muted">
{t("progress", { n: index + 1, total: STEPS.length })}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-6">
{step === "agent" && <AgentStep />}
{step === "look" && <LookStep />}
{step === "pet" && (
<StepHead title={t("pet.title")} body={t("pet.body")}>
<PetSettings />
</StepHead>
)}
{step === "notify" && <NotifyStep />}
</div>
<div className="flex items-center gap-2 border-t border-border px-8 py-4">
<button type="button" onClick={skip} className="btn-ghost text-xs">
{t("skip")}
</button>
<span className="flex-1" />
{index > 0 && (
<button
type="button"
onClick={() => setIndex(index - 1)}
className="btn-ghost border border-border text-xs"
>
<ChevronLeft className="h-3.5 w-3.5" />
{t("back")}
</button>
)}
<button
type="button"
onClick={next}
data-testid="onboarding-next"
className="btn-primary text-xs"
>
{last ? t("finish") : t("continue")}
<kbd className="ml-1 rounded border border-white/30 px-1 text-[10px] font-normal opacity-80">
Ctrl ↵
</kbd>
</button>
</div>
</div>
</div>
);
}
function StepHead({
eyebrow,
title,
body,
children,
}: {
eyebrow?: string;
title: string;
body: string;
children: ReactNode;
}) {
return (
<section className="space-y-6">
<div className="space-y-2">
{eyebrow && (
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{eyebrow}
</p>
)}
<h2 id="onboarding-title" className="text-3xl font-bold text-fg-primary">
{title}
</h2>
<p className="text-sm text-fg-secondary">{body}</p>
</div>
{children}
</section>
);
}
function AgentStep() {
const { t } = useTranslation("onboarding");
const aiTools = useAiTools();
const installed = aiTools.tools.filter((x) => x.installed && x.enabled);
const others = aiTools.tools.filter((x) => !(x.installed && x.enabled));
const [more, setMore] = useState(false);
const [picked, setPicked] = useState<string | null>(readDefaultTool);
const current =
(picked && installed.some((x) => x.id === picked) ? picked : null) ??
aiTools.defaultId ??
installed[0]?.id ??
null;
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
const pick = (id: string) => {
writeDefaultTool(id);
setPicked(id);
};
return (
<StepHead eyebrow={t("agent.eyebrow")} title={t("agent.title")} body={t("agent.body")}>
<div className="space-y-3">
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
{t("agent.detected", { count: installed.length })}
</p>
{aiTools.loaded && !installed.length && (
<p className="text-sm text-fg-muted">{t("agent.none")}</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{installed.map((tool) => {
const selected = tool.id === current;
return (
<button
key={tool.id}
type="button"
onClick={() => pick(tool.id)}
aria-pressed={selected}
data-testid={`onboarding-agent-${tool.id}`}
className={`relative flex items-center gap-3 rounded-xl border px-4 py-3.5 text-left transition-colors ${
selected
? "border-accent bg-accent/10 ring-1 ring-accent/50"
: "border-border bg-surface-2 hover:border-border-light"
}`}
>
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-fg-primary">
{tool.label}
</span>
<span className="block truncate font-mono text-xs text-fg-muted">
{tool.command || tool.id}
</span>
</span>
{selected && (
<span className="absolute right-2.5 top-2.5 flex h-5 w-5 items-center justify-center rounded-full bg-accent-solid text-white">
<Check className="h-3 w-3" />
</span>
)}
</button>
);
})}
{more &&
others.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-3.5 opacity-60"
>
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
<span className="min-w-0">
<span className="block truncate text-sm text-fg-primary">{tool.label}</span>
<span className="block text-xs text-fg-muted">{t("agent.notInstalled")}</span>
</span>
</div>
))}
</div>
{others.length > 0 && (
<button
type="button"
onClick={() => setMore(!more)}
className="text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
{more ? t("agent.less") : `${t("agent.more", { count: others.length })} →`}
</button>
)}
</div>
<div className="space-y-2 rounded-xl border border-border bg-surface-2 px-4 py-3.5">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-fg-primary">{t("agent.permission")}</span>
<SettingsSegmented
ariaLabel={t("agent.permission")}
value={mode}
onChange={(m) => {
writeDefaultMode(m);
setMode(m);
}}
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
/>
</div>
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
</div>
</StepHead>
);
}
const MODE_CARDS: { mode: ThemeMode; Icon: typeof Moon }[] = [
{ mode: "system", Icon: Monitor },
{ mode: "dark", Icon: Moon },
{ mode: "light", Icon: Sun },
];
function LookStep() {
const { t, i18n } = useTranslation("onboarding");
const { t: ts } = useTranslation("settings");
const { mode, themeFor, setMode, setThemeFor } = useTheme();
const [font, setFont] = useState<TerminalFont>(readTerminalFont);
return (
<StepHead title={t("look.title")} body={t("look.body")}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{MODE_CARDS.map(({ mode: m, Icon }) => {
const selected = mode === m;
return (
<button
key={m}
type="button"
onClick={() => setMode(m)}
aria-pressed={selected}
data-testid={`onboarding-mode-${m}`}
className={`rounded-xl border p-2.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
{m === "system" ? (
<div className="grid grid-cols-2 overflow-hidden rounded-md">
<Swatch mode="dark" name={themeFor.dark} />
<Swatch mode="light" name={themeFor.light} />
</div>
) : (
<Swatch mode={m} name={themeFor[m]} />
)}
<span className="flex items-center gap-1.5 px-1 pt-2 text-sm font-medium text-fg-primary">
<Icon className="h-3.5 w-3.5" />
{t(`look.modes.${m}`)}
<span className="ml-auto text-[11px] font-normal text-fg-muted">
{t(`look.modeHints.${m}`)}
</span>
</span>
</button>
);
})}
</div>
{(["dark", "light"] as Theme[]).map((group) => (
<div key={group} className="space-y-2">
<p className="text-xs font-medium text-fg-secondary">
{t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? ts("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}: ${label}`}
className={`rounded-lg border p-1 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1 text-[11px] text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
))}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium text-fg-secondary">{t("look.language")}</p>
<SettingsSegmented
ariaLabel={t("look.language")}
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
onChange={(v) => void i18n.changeLanguage(v)}
options={[
{ value: "en" as const, label: ts("language.names.en") },
{ value: "vi" as const, label: ts("language.names.vi") },
]}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-fg-secondary">{t("look.font")}</p>
<SettingsSegmented
ariaLabel={t("look.font")}
value={font}
onChange={(f) => {
writeTerminalFont(f);
setFont(f);
}}
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
value: f,
label: (
<span style={{ fontFamily: TERMINAL_FONTS[f] }}>
{ts(`terminalFont.names.${f}`)}
</span>
),
}))}
/>
</div>
</div>
<p className="text-xs text-fg-muted">{t("look.more")}</p>
</StepHead>
);
}
function NotifyStep() {
const { t } = useTranslation("onboarding");
const { t: ts } = useTranslation("settings");
const [prefs, setPrefs] = useState<NotifPrefs>(loadNotifPrefs);
const [result, setResult] = useState<string | null>(null);
const supported = typeof window !== "undefined" && "Notification" in window;
const [permission, setPermission] = useState(() =>
supported ? Notification.permission : "default"
);
const update = (patch: Partial<NotifPrefs>) => {
const next = { ...prefs, ...patch };
saveNotifPrefs(next);
setPrefs(next);
};
const enable = async (on: boolean) => {
if (!on) return update({ enabled: false });
if (supported && Notification.permission !== "granted") {
const perm = await Notification.requestPermission();
setPermission(perm);
if (perm !== "granted") return;
}
update({ enabled: true });
await subscribeToPush().catch(() => {
/* the local fallback still shows them */
});
};
const test = async () => {
const r = await notify(ts("notifications.testTitle"), ts("notifications.testBody"));
setResult(
r.via === "push" || r.via === "native"
? ts("notifications.testSent")
: r.via === "local"
? ts("notifications.testLocal")
: r.via === "blocked"
? ts("notifications.testBlocked")
: ts("notifications.testFailed")
);
};
return (
<StepHead title={t("notify.title")} body={t("notify.body")}>
<div className="space-y-4 rounded-xl border border-border bg-surface-2 px-4 py-4">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg-primary">{t("notify.enable")}</p>
<p className="text-xs text-fg-muted">{t("notify.enableHint")}</p>
</div>
<Switch
checked={prefs.enabled}
onChange={(v) => void enable(v)}
label={t("notify.enable")}
/>
</div>
{permission === "denied" && (
<p className="text-xs text-status-danger">{t("notify.blocked")}</p>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<span className="mr-2 text-sm text-fg-primary">{t("notify.sound")}</span>
<select
aria-label={t("notify.sound")}
value={prefs.sound}
onChange={(e) => {
const sound = e.target.value as NotifSound;
update({ sound });
playSound(sound);
}}
className="select py-1 text-xs"
>
{NOTIF_SOUNDS.map((k) => (
<option key={k} value={k}>
{ts(`notifications.sound.${k}`)}
</option>
))}
</select>
<button
type="button"
onClick={() => playSound(prefs.sound)}
disabled={prefs.sound === "none"}
className="btn-ghost text-xs disabled:opacity-50"
>
<Play className="h-3 w-3" />
{t("notify.preview")}
</button>
<button
type="button"
onClick={() => void test()}
className="btn-ghost border border-border text-xs"
>
<Zap className="h-3 w-3" />
{t("notify.test")}
</button>
</div>
{result && (
<p role="status" className="text-xs text-fg-secondary">
{result}
</p>
)}
</div>
</StepHead>
);
}
@@ -0,0 +1,256 @@
/**
* @file Onboarding.test.tsx
* @description The first-run setup (docs/plans/onboarding): the account's
* preferences are loaded into localStorage on sign-in and every later change
* is sent back; the starting choices never hold bypass; the wizard shows only
* for an account that is new to it, writes each pick like Settings does and
* ends in the tour; the tour follows the real UI step by step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const prefsMock = vi.fn();
const saveMock = vi.fn();
const onboardedMock = vi.fn();
const lanesMock = vi.fn();
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
me: {
prefs: () => prefsMock(),
savePrefs: (p: unknown) => saveMock(p),
onboarded: () => onboardedMock(),
},
lanes: { list: () => lanesMock() },
aiTools: {
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
list: async () => ({
default: "claude",
catalog: [],
tools: [
{
id: "claude",
label: "Claude Code",
command: "claude",
installed: true,
enabled: true,
},
{ id: "codex", label: "Codex", command: "codex", installed: true, enabled: true },
{ id: "kimi", label: "Kimi Code", command: "kimi", installed: false, enabled: true },
],
}),
},
},
};
});
vi.mock("../../settings/PetSettings", () => ({
PetSettings: () => <div data-testid="pet-settings" />,
}));
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
import { OnboardingWizard } from "../OnboardingWizard";
import { OnboardingTour, startTour } from "../OnboardingTour";
import { __resetAiTools } from "../../../hooks/useAiTools";
beforeEach(() => {
localStorage.clear();
__resetUserPrefs();
__resetAiTools();
prefsMock.mockReset();
saveMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: null });
onboardedMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: "2026-10-08T00:00:00Z" });
lanesMock.mockReset().mockResolvedValue({ lanes: [], counts: {} });
});
describe("account preferences", () => {
it("loads the account's values, sends up this browser's, then every change", async () => {
vi.useFakeTimers();
try {
localStorage.setItem("agenthub.terminal.font", "maple");
localStorage.setItem("theme", "dark");
prefsMock.mockResolvedValue({ prefs: { theme: "light" }, onboardedAt: null });
await hydrate();
expect(localStorage.getItem("theme")).toBe("light");
await vi.advanceTimersByTimeAsync(10);
expect(saveMock).toHaveBeenCalledWith(
expect.objectContaining({ "agenthub.terminal.font": "maple" })
);
saveMock.mockClear();
localStorage.setItem("agenthub.theme.dark", "darcula");
localStorage.setItem("unrelated.key", "x");
localStorage.removeItem("agenthub.pet");
await vi.advanceTimersByTimeAsync(500);
expect(saveMock).toHaveBeenCalledTimes(1);
expect(saveMock).toHaveBeenCalledWith({
"agenthub.theme.dark": "darcula",
"agenthub.pet": null,
});
} finally {
vi.useRealTimers();
}
});
it("never keeps bypass as the starting permission mode", () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
expect(readDefaultMode()).toBe("auto");
writeDefaultMode("bypassPermissions" as never);
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
writeDefaultMode("plan");
expect(readDefaultMode()).toBe("plan");
});
});
function Probe() {
const s = useOnboarding();
return <span data-testid="onboarded">{s.onboardedAt ?? "no"}</span>;
}
const renderWizard = () =>
render(
<MemoryRouter>
<OnboardingWizard />
<Probe />
</MemoryRouter>
);
describe("the setup wizard", () => {
it("does not show before the account's preferences load, nor once done", async () => {
renderWizard();
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: "2026-01-01T00:00:00Z" });
await act(() => hydrate());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
});
it("skips itself for an account that already has a workspace", async () => {
lanesMock.mockResolvedValue({ lanes: [{ id: 1 }], counts: {} });
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
});
it("walks four steps, saving each pick, and ends in the tour", async () => {
const user = userEvent.setup();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await screen.findByTestId("onboarding-wizard");
expect(screen.getByText("Pick your default agent")).toBeInTheDocument();
await user.click(await screen.findByTestId("onboarding-agent-codex"));
expect(localStorage.getItem("agenthub.defaultTool")).toBe("codex");
// A tool that is not installed is not offered until asked for, and never pickable.
expect(screen.queryByText("Kimi Code")).toBeNull();
await user.click(screen.getByText(/Show 1 more agents/));
expect(screen.getByText("Kimi Code").closest("button")).toBeNull();
await user.click(screen.getByRole("radio", { name: "Plan" }));
expect(localStorage.getItem("agenthub.permissionMode")).toBe("plan");
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByText("Make it feel like home")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-mode-light"));
expect(localStorage.getItem("theme")).toBe("light");
await user.click(screen.getByRole("button", { name: "Light theme: Canvas" }));
expect(localStorage.getItem("agenthub.theme.light")).toBe("canvas");
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByTestId("pet-settings")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByText("Set up notifications")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Add your first workspace/ }));
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
expect(localStorage.getItem("agenthub.tour")).toBe("add");
});
it("is skipped for good with Skip setup", async () => {
const user = userEvent.setup();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await user.click(await screen.findByRole("button", { name: "Skip setup" }));
await waitFor(() => expect(screen.getByTestId("onboarded").textContent).not.toBe("no"));
expect(onboardedMock).toHaveBeenCalled();
expect(localStorage.getItem("agenthub.tour")).toBeNull();
});
});
describe("the tour", () => {
const mount = (html: string) => {
const host = document.createElement("div");
host.innerHTML = html;
document.body.appendChild(host);
return host;
};
// jsdom has no layout: give the targets a box.
const boxed = () =>
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
x: 10,
y: 10,
left: 10,
top: 10,
width: 100,
height: 30,
right: 110,
bottom: 40,
toJSON: () => ({}),
} as DOMRect);
it("follows the real UI: add → folder → create → prompt", async () => {
const spy = boxed();
const user = userEvent.setup();
const page = mount('<button data-testid="workspace-tree-add">+</button>');
try {
startTour();
render(
<MemoryRouter initialEntries={["/workspace"]}>
<OnboardingTour />
</MemoryRouter>
);
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
// The dialog opened: the tour moves on by itself.
const dialog = mount(
'<div data-tour="add-lane"><div data-tour="add-lane-folder"></div><button data-tour="add-lane-create"></button></div>'
);
expect(await screen.findByText("Choose the project folder")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-tour-next"));
expect(screen.getByText("Create it")).toBeInTheDocument();
// Created: the dialog is gone and the workspace's prompt box is there.
dialog.remove();
page.innerHTML = '<div data-testid="workspace-home-composer"></div>';
expect(await screen.findByText("Start your first session")).toBeInTheDocument();
expect(localStorage.getItem("agenthub.tour")).toBe("prompt");
await user.click(screen.getByRole("button", { name: "Skip tour" }));
expect(screen.queryByTestId("onboarding-tour")).toBeNull();
expect(localStorage.getItem("agenthub.tour")).toBe("done");
} finally {
spy.mockRestore();
page.remove();
}
});
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
const spy = boxed();
const page = mount('<button data-testid="workspace-tree-add">+</button>');
try {
localStorage.setItem("agenthub.tour", "create");
render(
<MemoryRouter initialEntries={["/workspace"]}>
<OnboardingTour />
</MemoryRouter>
);
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
} finally {
spy.mockRestore();
page.remove();
}
});
});
+430
View File
@@ -0,0 +1,430 @@
/**
* @file ChatAddMenu.tsx
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
* files (the prompt box's own picker), what is already there to attach —
* Project files (a search of the box's folder, folders too:
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
* `lib/promptAttach.ts`) —, Skills (the user's and the project's)
* and Plugins → one plugin → the skills and commands it ships. Picking one
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
* and Manage opens the Claude config page on that tab.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ChevronRight,
FileText,
FolderOpen,
History,
Paperclip,
Plug,
Plus,
ScrollText,
Settings2,
} from "lucide-react";
import { api } from "../../lib/api";
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
interface Entry {
name: string;
description?: string;
}
interface Lists {
skills: Entry[];
plugins: { name: string; items: Entry[] }[];
}
const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name);
function loadLists(cwd: string): Promise<Lists> {
return Promise.all([
api.ccConfig.skills(undefined, cwd).catch(() => ({ items: [] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
]).then(([sk, pl]) => ({
skills: sk.items
.map((s) => ({ name: s.name, description: s.frontmatter?.description }))
.filter((s, i, all) => all.findIndex((o) => o.name === s.name) === i)
.sort(byName),
plugins: pl.plugins
.filter((p) => p.enabled !== false && p.contributes)
.map((p) => ({
name: p.name,
items: [...p.contributes!.items.skills, ...p.contributes!.items.commands]
.map((it) => ({ name: it.name, description: it.description ?? undefined }))
.filter((it, i, all) => all.findIndex((o) => o.name === it.name) === i)
.sort(byName),
}))
.filter((p, i, all) => p.items.length > 0 && all.findIndex((o) => o.name === p.name) === i)
.sort(byName),
}));
}
const PANEL =
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
const ROW =
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
function Row({
icon,
label,
more,
active,
onClick,
onHover,
title,
testId,
}: {
icon: ReactNode;
label: string;
more?: boolean;
active?: boolean;
onClick: () => void;
onHover?: () => void;
title?: string;
testId?: string;
}) {
return (
<button
type="button"
role="menuitem"
title={title}
data-testid={testId}
onClick={onClick}
onMouseEnter={onHover}
className={`${ROW} ${active ? "bg-surface-3 text-fg-primary" : ""}`}
>
<span className="shrink-0 text-fg-muted">{icon}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{more && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-muted" />}
</button>
);
}
/** Project files: a search of the box's folder, shortest paths first. */
function ProjectFiles({ cwd, onPick }: { cwd: string; onPick: (rel: string) => void }) {
const { t } = useTranslation("run");
const [q, setQ] = useState("");
const [items, setItems] = useState<string[] | null>(null);
useEffect(() => {
let live = true;
const tid = window.setTimeout(() => {
api.run
.files(cwd, q, true)
.then((r) => live && setItems(r.items))
.catch(() => live && setItems([]));
}, 120);
return () => {
live = false;
window.clearTimeout(tid);
};
}, [cwd, q]);
return (
<>
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("chat.searchFiles")}
data-testid="chat-add-project-search"
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none"
/>
<div className="max-h-72 overflow-y-auto">
{!items || items.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-fg-muted">{items ? t("chat.none") : "…"}</p>
) : (
items.map((p) => (
<Row
key={p}
icon={
p.endsWith("/") ? (
<FolderOpen className="h-4 w-4" />
) : (
<FileText className="h-4 w-4" />
)
}
label={p}
title={p}
testId={`chat-add-project-${p}`}
onClick={() => onPick(p)}
/>
))
)}
</div>
</>
);
}
type Sub = "project" | "context" | "recent" | "skills" | "plugins";
export function ChatAddMenu({
cwd,
laneId = null,
skills,
onUpload,
onInsert,
onAttach,
}: {
cwd: string;
/** The workspace, for its Context files; none hides them. */
laneId?: number | null;
/** Offer Skills and Plugins: Claude only, they are Claude Code's. */
skills: boolean;
onUpload: () => void;
/** `/<name>` picked. */
onInsert: (command: string) => void;
/** An existing file picked: a project path, a context file, an upload. */
onAttach: (items: Attachment[]) => void;
}) {
const { t } = useTranslation("run");
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null);
const [recent, setRecent] = useState<Attachment[]>([]);
const [plugin, setPlugin] = useState<string | null>(null);
const [lists, setLists] = useState<Lists | null>(null);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
if (skills && !lists) void loadLists(cwd).then(setLists);
const away = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", away);
document.addEventListener("keydown", esc);
return () => {
document.removeEventListener("mousedown", away);
document.removeEventListener("keydown", esc);
};
}, [open, skills, lists, cwd]);
// Read again at each opening: either list may have grown since.
useEffect(() => {
if (!open) return;
setRecent(recentUploads());
setContext(null);
if (laneId === null) return;
let live = true;
api.lanes
.sources(laneId)
.then(
(v) =>
live &&
setContext(
v.sources
.filter((s) => s.kind === "file" && !s.missing)
.map((s) => ({ path: `@${s.path}`, name: s.name }))
)
)
.catch(() => live && setContext([]));
return () => {
live = false;
};
}, [open, laneId]);
const close = () => {
setOpen(false);
setSub(null);
setPlugin(null);
};
const pick = (command: string) => {
close();
onInsert(command);
};
const attach = (item: Attachment) => {
close();
onAttach([item]);
};
const manage = (tab: "skills" | "plugins") => {
close();
navigate(`/cc-config?tab=${tab}`);
};
const entries = (items: Entry[], prefix: string, from?: string) =>
items.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-fg-muted">{lists ? t("chat.none") : "…"}</p>
) : (
items.map((it) => (
<Row
key={it.name}
icon={<ScrollText className="h-4 w-4" />}
label={it.name}
title={[it.description, from].filter(Boolean).join("\n") || undefined}
testId={`chat-add-skill-${prefix}${it.name}`}
onClick={() => pick(`/${prefix}${it.name}`)}
/>
))
);
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
return (
<div ref={ref} className="relative">
<button
type="button"
data-testid="chat-add"
aria-label={t("chat.add")}
title={t("chat.add")}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Plus className="h-4 w-4" />
</button>
{open && (
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
<Row
icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")}
testId="chat-add-upload"
onHover={() => setSub(null)}
onClick={() => {
close();
onUpload();
}}
/>
<Row
icon={<FolderOpen className="h-4 w-4" />}
label={t("chat.projectFiles")}
more
active={sub === "project"}
testId="chat-add-project"
onHover={() => setSub("project")}
onClick={() => setSub(sub === "project" ? null : "project")}
/>
{laneId !== null && (
<Row
icon={<FileText className="h-4 w-4" />}
label={t("chat.contextFiles")}
more
active={sub === "context"}
testId="chat-add-context"
onHover={() => setSub("context")}
onClick={() => setSub(sub === "context" ? null : "context")}
/>
)}
<Row
icon={<History className="h-4 w-4" />}
label={t("chat.recentUploads")}
more
active={sub === "recent"}
testId="chat-add-recent"
onHover={() => setSub("recent")}
onClick={() => setSub(sub === "recent" ? null : "recent")}
/>
{skills && (
<>
<Row
icon={<ScrollText className="h-4 w-4" />}
label={t("chat.skills")}
more
active={sub === "skills"}
testId="chat-add-skills"
onHover={() => setSub("skills")}
onClick={() => setSub(sub === "skills" ? null : "skills")}
/>
<Row
icon={<Plug className="h-4 w-4" />}
label={t("chat.plugins")}
more
active={sub === "plugins"}
testId="chat-add-plugins"
onHover={() => setSub("plugins")}
onClick={() => setSub(sub === "plugins" ? null : "plugins")}
/>
</>
)}
{sub === "project" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
</div>
)}
{(sub === "context" || sub === "recent") && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => (
<Row
key={it.path}
icon={<FileText className="h-4 w-4" />}
label={it.name}
title={it.path.replace(/^@/, "")}
testId={`chat-add-${sub}-${it.name}`}
onClick={() => attach(it)}
/>
))
) : (
<p className="px-3 py-1.5 text-xs text-fg-muted">
{sub === "context" && !context ? "…" : t("chat.none")}
</p>
)}
</div>
</div>
)}
{sub === "skills" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
<div className="my-1 border-t border-border" />
<Row
icon={<Settings2 className="h-4 w-4" />}
label={t("chat.manageSkills")}
onClick={() => manage("skills")}
/>
</div>
)}
{sub === "plugins" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{lists && lists.plugins.length === 0 && (
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
)}
{!lists && <p className="px-3 py-1.5 text-xs text-fg-muted">…</p>}
{lists?.plugins.map((p) => (
<Row
key={p.name}
icon={<Plug className="h-4 w-4" />}
label={p.name}
more
active={plugin === p.name}
testId={`chat-add-plugin-${p.name}`}
onHover={() => setPlugin(p.name)}
onClick={() => setPlugin(plugin === p.name ? null : p.name)}
/>
))}
</div>
<div className="my-1 border-t border-border" />
<Row
icon={<Settings2 className="h-4 w-4" />}
label={t("chat.managePlugins")}
onClick={() => manage("plugins")}
/>
{shownPlugin && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{entries(
shownPlugin.items,
`${shownPlugin.name}:`,
t("chat.fromPlugin", { name: shownPlugin.name })
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,260 @@
/**
* @file ChatFilePreview.tsx
* @description A file a Chat pane's session touched, shown the way Claude
* Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the
* content) and `FileListModal` (a list on the left, the picked file's preview
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } 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 { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
/** Where a Chat pane's files can be read from. */
export interface PreviewScope {
laneId: number | null;
/** The workspace's own folder. */
laneCwd: string;
/** The pane's folder — the workspace's, or the session's own worktree. */
cwd: string;
sessionId: string;
}
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/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]+)$/;
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));
return m ? m[1]!.toUpperCase() : "";
};
/** An attached file's name without the drop dir's random prefix. */
export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
type Content =
| { kind: "loading" }
| { kind: "image"; src: string }
| { kind: "text"; text: string; truncated: boolean }
| { kind: "none" };
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)) {
show(
api.ccConfig
.file(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 target =
base === scope.laneCwd.replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return;
}
show(
api.lanes
.gitFile(target, rel)
.then((f) =>
f.available && !f.binary
? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated }
: { kind: "none" }
)
);
return () => {
live = false;
};
}, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]);
return content;
}
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
const { t } = useTranslation("run");
const content = useContent(path, scope);
if (content.kind === "loading") {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
if (content.kind === "image") {
return (
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
<img
src={content.src}
alt={baseName(path)}
className="max-h-full max-w-full object-contain"
/>
</div>
);
}
if (content.kind === "none") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p>{t("chat.outline.noPreview")}</p>
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
return (
<div className="min-h-0 flex-1 overflow-auto">
{isMarkdown(path) ? (
<MarkdownPreview text={content.text} className="p-4" />
) : (
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
{content.text}
</pre>
)}
{content.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
)}
</div>
);
}
/** One file: header (name · EXT, Copy path, Close) over its content. */
export function FilePreview({
path,
scope,
onClose,
className = "",
}: {
path: string;
scope: PreviewScope;
onClose: () => void;
className?: string;
}) {
const { t } = useTranslation("run");
return (
<section
data-testid="chat-file-preview"
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
>
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="text-xs text-fg-muted">{extOf(path)}</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
aria-label={t("chat.outline.copyPath", { path })}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onClose}
title={t("chat.outline.close")}
aria-label={t("chat.outline.close")}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
<Body path={path} scope={scope} />
</section>
);
}
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
export function FileListModal({
title,
paths,
scope,
onClose,
}: {
title: string;
paths: string[];
scope: PreviewScope;
onClose: () => void;
}) {
const [picked, setPicked] = useState(paths[0] ?? "");
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
// Into document.body, like ImageLightbox: a pane's own stacking layer
// would otherwise draw over a `fixed` overlay opened from inside it.
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-0/70 p-6"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
data-testid="chat-file-modal"
onClick={(e) => e.stopPropagation()}
className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
>
<div className="flex w-80 flex-shrink-0 flex-col border-r border-border">
<h2 className="px-5 pb-3 pt-5 text-lg text-fg-primary">{title}</h2>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
{paths.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setPicked(p)}
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>
</button>
</li>
))}
</ul>
</div>
{picked && <FilePreview path={picked} scope={scope} onClose={onClose} className="flex-1" />}
</div>
</div>,
document.body
);
}
+302
View File
@@ -0,0 +1,302 @@
/**
* @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane shown alone, 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
* 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
* `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 { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
BookOpen,
Check,
ChevronDown,
FileCode,
FileImage,
FileSearch,
FileSpreadsheet,
FileText,
History,
Paperclip,
Plug,
X,
} from "lucide-react";
import { api, type SessionOutline } from "../../lib/api";
import { displayName, extOf } from "./ChatFilePreview";
const POLL_MS = 5000;
const EMPTY: SessionOutline = {
progress: [],
outputs: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
};
/** The session's outline, re-read on a visibility-gated poll. */
export function useSessionOutline(sessionId: string): SessionOutline {
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
useEffect(() => {
let live = true;
const load = () =>
api.sessions
.outline(sessionId)
.then((o) => live && setOutline({ ...EMPTY, ...o }))
.catch(() => {});
void load();
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") void load();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
return outline;
}
export type OutlineList = "uploads" | "files" | "memory";
function fileIcon(path: string) {
const ext = extOf(path).toLowerCase();
if (["xlsx", "xls", "csv", "tsv"].includes(ext)) return FileSpreadsheet;
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return FileImage;
if (
/^(c|cc|cpp|cs|css|go|h|html|java|js|jsx|json|kt|mjs|cjs|php|py|rb|rs|sh|sql|swift|ts|tsx|vue|ya?ml|toml)$/.test(
ext
)
)
return FileCode;
return FileText;
}
function Section({
title,
count,
action,
grow,
children,
}: {
title: string;
count?: number;
action?: ReactNode;
/** Takes the panel's free height and scrolls inside it. */
grow?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className={`flex flex-col px-3 pt-3 ${grow && open ? "min-h-0 flex-1" : ""}`}>
<div className="flex items-center gap-1.5 px-1 pb-1">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-1.5 text-sm font-medium text-fg-primary"
>
{title}
{count ? <span className="text-xs font-normal text-fg-muted">{count}</span> : null}
<ChevronDown
className={`h-3.5 w-3.5 text-fg-muted transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
<div className="ml-auto flex items-center">{action}</div>
</div>
{open && (
<div
className={
grow
? "min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,transparent,black_8px,black_calc(100%-8px),transparent)]"
: ""
}
>
{children}
</div>
)}
</section>
);
}
const rowClass =
"flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-[13px] text-fg-secondary enabled:hover:bg-surface-3";
/** A "Used in this session" row: icon, label, the first names, +N. */
function UsedRow({
icon: Icon,
label,
names,
onClick,
to,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
names: string[];
onClick?: () => void;
to?: string;
}) {
const shown = names.slice(0, 2).join(", ");
const inner = (
<>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="flex-shrink-0 text-fg-primary">{label}</span>
<span className="min-w-0 flex-1 truncate text-xs text-fg-muted">{shown}</span>
{names.length > 2 && (
<span className="flex-shrink-0 text-xs text-fg-muted">+{names.length - 2}</span>
)}
</>
);
const title = `${label}: ${names.join(", ")}`;
return to ? (
<Link to={to} title={title} className={`${rowClass} hover:bg-surface-3`}>
{inner}
</Link>
) : (
<button type="button" title={title} onClick={onClick} className={rowClass}>
{inner}
</button>
);
}
const stem = (p: string) => displayName(p).replace(/\.[A-Za-z0-9]+$/, "");
export function ChatOutline({
outline: o,
onClose,
onOpenFile,
onOpenList,
}: {
outline: SessionOutline;
onClose: () => void;
onOpenFile: (path: string) => void;
onOpenList: (list: OutlineList) => void;
}) {
const { t } = useTranslation("run");
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
type="button"
onClick={onClose}
title={t("chat.outline.hide")}
aria-label={t("chat.outline.hide")}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
);
return (
<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"
>
{o.progress.length > 0 && (
<Section title={t("chat.outline.progress")} action={close}>
{o.progress.map((task) => {
const done = task.status === "completed";
return (
<div key={task.id} className={rowClass} title={task.subject}>
{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"}`}
/>
)}
<span
className={`min-w-0 flex-1 truncate ${done ? "text-fg-muted line-through" : task.status === "in_progress" ? "text-fg-primary" : ""}`}
>
{task.subject}
</span>
</div>
);
})}
</Section>
)}
<Section
title={t("chat.outline.outputs")}
count={o.outputs.length}
action={o.progress.length ? undefined : close}
grow
>
{o.outputs.length ? (
o.outputs.map((p) => {
const Icon = fileIcon(p);
return (
<button
key={p}
type="button"
title={p}
aria-label={`${extOf(p)}: ${stem(p)}`}
onClick={() => onOpenFile(p)}
className={rowClass}
>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate">{stem(p)}</span>
<span className="flex-shrink-0 text-[11px] text-fg-muted">{extOf(p)}</span>
</button>
);
})
) : (
<p className="px-1.5 py-1 text-xs text-fg-muted">{t("chat.outline.empty")}</p>
)}
</Section>
{used > 0 && (
<Section title={t("chat.outline.used")}>
{o.uploads.length > 0 && (
<UsedRow
icon={Paperclip}
label={t("chat.outline.uploads")}
names={o.uploads.map(displayName)}
onClick={() => onOpenList("uploads")}
/>
)}
{o.files.length > 0 && (
<UsedRow
icon={FileSearch}
label={t("chat.outline.files")}
names={o.files.map(displayName)}
onClick={() => onOpenList("files")}
/>
)}
{o.memory.length > 0 && (
<UsedRow
icon={History}
label={t("chat.outline.memory")}
names={o.memory.map(stem)}
onClick={() => onOpenList("memory")}
/>
)}
{o.skills.length > 0 && (
<UsedRow
icon={BookOpen}
label={t("chat.skills")}
names={o.skills}
to="/settings/agents/claude-code?tab=skills"
/>
)}
{o.mcp.length > 0 && (
<UsedRow
icon={Plug}
label={t("chat.outline.connectors")}
names={o.mcp}
to="/settings/agents/claude-code?tab=mcp"
/>
)}
</Section>
)}
</aside>
);
}
+12 -6
View File
@@ -14,8 +14,9 @@
* tab) attaches to that tmux session only; nothing is started or resumed.
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
* conversation laid OVER the terminal, which stays mounted and attached under
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
* left of the pane's split buttons); the Chat UI setting (Settings →
* Appearance, `lib/chatUi.ts`) is only what a session shows first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -28,7 +29,7 @@ import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { PaneChat } from "./PaneChat";
import { useChatUi } from "../../lib/chatUi";
import { useSessionChat } from "../../lib/chatUi";
const CHAT_TOOLS = new Set(["claude", "codex"]);
@@ -50,6 +51,8 @@ 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 tmux session a lane's tab owns (see server/lib/pty-run.js). */
@@ -68,6 +71,7 @@ export function LaneConsolePane({
onNewSession,
onRunExit,
autoFocus,
solo = false,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const [handle, setHandle] = useState<RunHandle | null>(null);
@@ -78,11 +82,11 @@ export function LaneConsolePane({
// The tool of the run on screen — a session's own, which need not be the
// workspace's; the workspace's with no run.
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
const chat = useChatUi();
// A run learns its session id after it starts (Codex): the page's poll has it.
const chatSession = handle
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
: null;
const chat = useSessionChat(chatSession);
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
useEffect(() => {
@@ -192,10 +196,12 @@ export function LaneConsolePane({
{chatable && chat && (
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
<PaneChat
runId={handle.id}
run={handle}
sessionId={chatSession}
tool={handle.tool}
cwd={handle.cwd || currentLane?.cwd || ""}
laneId={laneId}
laneCwd={currentLane?.cwd || ""}
outline={solo}
/>
</div>
)}
+277 -41
View File
@@ -5,83 +5,319 @@
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
* attached underneath — a message is sent through that terminal
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
* nothing new reaches the server. A permission prompt or a menu still needs the
* terminal; the hint under the box says so.
* nothing new reaches the server. The box carries Claude Desktop's controls:
* the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's
* context files, recent uploads —, skills, plugins' skills), and for a
* Claude pane the Model/Effort chip — a change is typed into the pane as
* `/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
* (`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
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. 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`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp } from "lucide-react";
import { ArrowUp, FileText, Square } from "lucide-react";
import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { submitToRun } from "../../lib/panePaste";
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 { Select } from "../Select";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [
["default", "Default"],
["acceptEdits", "AcceptEdits"],
["plan", "Plan"],
["auto", "Auto"],
["bypassPermissions", "Bypass"],
];
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
/** The pane's permission mode as it shows it; a pick steps it there. */
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
const { t } = useTranslation(["run", "lanes"]);
const [mode, setMode] = useState<PermissionMode>(initial ?? "default");
const [busy, setBusy] = useState(false);
const read = useCallback(
() =>
api.run
.permissionMode(runId)
.then((r) => isSettable(r.mode) && setMode(r.mode))
.catch(() => {}),
[runId]
);
useEffect(() => {
void read();
}, [read]);
// Bypass is in a session's cycle only when it was started with it.
const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v);
return (
<div data-testid="pane-chat-mode">
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
disabled={busy}
onChange={(v) => {
setBusy(true);
api.run
.setPermissionMode(runId, v)
.then((r) => setMode(r.mode))
.catch((err: unknown) => {
toast.error(
t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" })
);
void read();
})
.finally(() => setBusy(false));
}}
options={offered.map(([value, key]) => ({
value,
label: t(`lanes:home.permission${key}`),
hint: t(`run:fields.permission${key}`),
}))}
/>
</div>
);
}
export function PaneChat({
runId,
run,
sessionId,
tool,
cwd,
laneId = null,
laneCwd = "",
outline = false,
}: {
runId: string;
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
sessionId: string;
tool: string;
cwd: string;
/** 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;
}) {
const { t } = useTranslation("run");
const runId = run.id;
const claude = run.tool === "claude";
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
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");
const openOutline = (open: boolean) => {
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
setOutlineOpen(open);
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId);
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.
const [working, setWorking] = useState(false);
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")))
.catch(() => {}),
[sessionId]
);
useEffect(() => {
void readWorking();
}, [readWorking]);
useLiveRefresh(readWorking, {
throttleMs: 500,
// 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,
match: (msg) =>
msg.type === "agent_updated" &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// A control is a command typed into the pane, like the user would.
const command = (line: string) => {
const ok = submitToRun(runId, line);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = () => {
if (!text || uploading) return;
if (!submitToRun(runId, text)) {
toast.error(t("chat.notSent"));
return;
}
if (!command(text)) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
};
return (
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} fill />
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
value={prompt}
onChange={setPrompt}
onSubmit={send}
placeholder={t("chat.placeholder")}
rows={2}
slash={tool === "claude"}
fileCwd={cwd}
<div className="relative flex min-h-0 flex-1">
<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">
<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);
}}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
{outline && !outlineOpen && !preview && (
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
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"
>
<ArrowUp className="h-4 w-4" />
<FileText className="h-3.5 w-3.5" />
{activity.outputs.length}
</button>
)}
</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">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
pickFiles={pickFiles}
value={prompt}
onChange={setPrompt}
onSubmit={send}
placeholder={t("chat.placeholder")}
rows={2}
slash={claude}
fileCwd={cwd}
lane={runId}
/>
<div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={cwd}
laneId={laneId}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
<div className="ml-auto flex items-center gap-1">
{claude && (
<ModelMenu
value={model}
onChange={(v) => {
if (command(`/model ${v || "default"}`)) setModel(v);
}}
tool={run.tool}
effort={effort}
onEffortChange={(e) => {
if (command(`/effort ${e || "auto"}`)) setEffort(e);
}}
/>
)}
{working && !text ? (
<button
type="button"
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"));
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
>
<Square className="h-3 w-3 fill-current" />
</button>
) : (
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
</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"
/>
) : (
outlineOpen && (
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenList={setList}
/>
)
))}
{list && (
<FileListModal
title={t(`chat.outline.${list}`)}
paths={activity[list]}
scope={scope}
onClose={() => setList(null)}
/>
)}
</div>
);
}
+36 -3
View File
@@ -29,6 +29,7 @@ import {
ChevronDown,
Columns2,
GripVertical,
MessageSquare,
Minimize2,
Rows2,
SquareTerminal,
@@ -55,6 +56,7 @@ import {
} from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -191,9 +193,37 @@ function PaneMenu({
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";
/** A pane's split-right / split-down menus: move a hidden run beside
* `slot`, or start a new terminal or AI tool there. In a pane header when the view is
* split, beside Tools in the tab bar while one pane is shown. */
/** 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. */
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
const sessionId = run.sessionId;
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
return (
<button
type="button"
data-testid={`pane-chat-toggle-${run.slot ?? 0}`}
aria-pressed={chat}
title={label}
aria-label={label}
onClick={() => setSessionChat(sessionId, !chat)}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
{chat ? (
<SquareTerminal className="h-3.5 w-3.5" />
) : (
<MessageSquare className="h-3.5 w-3.5" />
)}
</button>
);
}
/** A pane's chat/terminal toggle and split-right / split-down menus: move a
* hidden run beside `slot`, or start a new terminal or AI tool there. In a
* pane header when the view is split, beside Tools in the tab bar while one
* pane is shown. */
export function SplitMenus({
slot,
shown,
@@ -222,6 +252,7 @@ export function SplitMenus({
const full = shown.length >= MAX_PANES;
return (
<>
<ChatToggle run={runs.find((x) => (x.slot ?? 0) === slot)} />
{(["right", "bottom"] as const).map((edge) => (
<PaneMenu
key={edge}
@@ -446,6 +477,8 @@ export function PaneGrid({
key={slot}
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"}
style={r ? pct(r) : undefined}
onPointerDownCapture={() => r && focus(slot)}
+96 -32
View File
@@ -6,8 +6,12 @@
* box (`WorkspaceHome.tsx`):
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
* newlines, and pasted or dropped files (images as thumbnails, any other
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
* 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;
* 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`)
* or a fixed one (an AI Proxy's models), plus Custom…;
@@ -19,7 +23,17 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import {
Check,
@@ -42,9 +56,16 @@ import {
} from "../../lib/api";
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
import type { LaneTool } from "../../lib/types";
import { Select } from "../Select";
import { Select, menuRoom } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
import { ImageLightbox } from "../ImageLightbox";
import {
ATTACH_MIME,
addAttachments,
droppedAttachment,
rememberUploads,
} from "../../lib/promptAttach";
export interface SlashCommand {
name: string;
@@ -158,11 +179,16 @@ interface PromptEditorProps {
* sends them with the text through `promptWithImages`. */
images: PromptImage[];
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */
/** Borderless, for a composer box that draws its own frame — and grows
* with its text, from `rows` up to 40% of the screen, then scrolls. */
bare?: boolean;
/** An image upload started (true) or settled (false): the caller holds its
* send button meanwhile, or the prompt would go out without the image. */
onBusyChange?: (busy: boolean) => void;
/** The workspace (id, or a pane's run id) whose panes read the files. */
lane?: number | string;
/** Set to a function that opens a file picker (a composer's + menu). */
pickFiles?: MutableRefObject<(() => void) | null>;
}
/** An attached file: its path on the server's machine, and a local preview
@@ -175,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
export const PASTE_AS_FILE_CHARS = 5000;
export function PromptEditor({
value,
onChange,
@@ -187,9 +216,19 @@ export function PromptEditor({
onImagesChange,
bare,
onBusyChange,
lane,
pickFiles,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
// 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(() => {
const ta = taRef.current;
if (!bare || !ta) return;
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [bare, value]);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
@@ -321,11 +360,11 @@ export function PromptEditor({
const busyRef = useRef(onBusyChange);
busyRef.current = onBusyChange;
useEffect(() => busyRef.current?.(uploading), [uploading]);
const attachImages = (list: FileList | null | undefined) => {
const attachImages = (list: FileList | File[] | null | undefined) => {
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropFile(f)))
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
@@ -333,6 +372,7 @@ export function PromptEditor({
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
onImagesChange((prev) => [...prev, ...added]);
})
.catch(() => setUpload("failed"));
@@ -342,12 +382,11 @@ export function PromptEditor({
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
};
useEffect(() => {
if (!viewing) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [viewing]);
const closeViewer = useCallback(() => setViewing(null), []);
// The composer's own "Upload or add files" opens this picker; what it picks
// takes the same path as a paste or a drop.
const fileInput = useRef<HTMLInputElement | null>(null);
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) {
@@ -451,6 +490,19 @@ export function PromptEditor({
)}
</div>
)}
{pickFiles && (
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="prompt-file-input"
onChange={(e) => {
attachImages(e.target.files);
e.target.value = "";
}}
/>
)}
<textarea
ref={taRef}
value={value}
@@ -464,8 +516,28 @@ export function PromptEditor({
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}
@@ -473,7 +545,7 @@ export function PromptEditor({
spellCheck={false}
className={
bare
? "block w-full resize-none bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
? "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"
}
/>
@@ -542,20 +614,7 @@ export function PromptEditor({
)}
</div>
)}
{viewing && (
<div
role="dialog"
aria-label={viewing.name}
onClick={() => setViewing(null)}
className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={viewing.url}
alt={viewing.name}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>
)}
{viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
</div>
);
}
@@ -670,7 +729,10 @@ export function CwdAutocomplete({
/>
</div>
{open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
<div
data-cwd-suggestions
className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"
>
{groups.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
) : (
@@ -853,9 +915,11 @@ export function ModelMenu({
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
// Below the chip unless the viewport has more room above it.
const r = ref.current?.getBoundingClientRect();
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom);
// Below the chip unless its pane (or the viewport) has more room above it.
if (ref.current) {
const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
setUp(below < 360 && above > below);
}
const away = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
+8 -2
View File
@@ -192,7 +192,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (files.length === 0) return;
setDrop("uploading");
try {
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
@@ -236,6 +236,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
fontSize: 13,
cursorBlink: true,
fontFamily: TERMINAL_FONTS[readTerminalFont()],
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
fontWeight: 500,
fontWeightBold: 700,
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
});
@@ -556,7 +559,10 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// On screen (a console hidden with CSS measures 0×0) AND attached:
// `onData` drops input while the socket is connecting or reconnecting,
// so a caller waits or says the paste failed instead.
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
(data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
}
),
[runId]
);
@@ -0,0 +1,103 @@
/**
* @file ChatAddMenu.test.tsx
* @description The `+` menu's existing-resource pickers: Project files (a
* search with folders, attached as `@path`), the workspace's Context files
* (files only, none missing) and this browser's Recent uploads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ChatAddMenu } from "../ChatAddMenu";
import { api } from "../../../lib/api";
import { rememberUploads } from "../../../lib/promptAttach";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
lanes: {
sources: vi.fn().mockResolvedValue({
sources: [
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
{ id: 2, kind: "file", path: "/data/ctx/1/gone.md", name: "gone.md", missing: true },
{ id: 3, kind: "folder", path: "/srv/docs", name: "docs", missing: false },
],
}),
},
ccConfig: {
skills: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
},
},
};
});
function renderMenu(laneId: number | null = 7) {
const onAttach = vi.fn();
render(
<MemoryRouter>
<ChatAddMenu
cwd="/repo"
laneId={laneId}
skills={false}
onUpload={() => {}}
onInsert={() => {}}
onAttach={onAttach}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByTestId("chat-add"));
return onAttach;
}
beforeEach(() => {
i18n.changeLanguage("en");
localStorage.clear();
vi.clearAllMocks();
});
describe("ChatAddMenu — existing resources", () => {
it("searches the project, folders included, and attaches a pick as @path", async () => {
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-project"));
fireEvent.click(await screen.findByTestId("chat-add-project-src/"));
expect(api.run.files).toHaveBeenCalledWith("/repo", "", true);
expect(onAttach).toHaveBeenCalledWith([{ path: "@src/", name: "src/" }]);
});
it("offers the workspace's context files that are still there, files only", async () => {
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-context"));
fireEvent.click(await screen.findByTestId("chat-add-context-brief.md"));
expect(api.lanes.sources).toHaveBeenCalledWith(7);
expect(screen.queryByTestId("chat-add-context-gone.md")).toBeNull();
expect(onAttach).toHaveBeenCalledWith([{ path: "@/data/ctx/1/brief.md", name: "brief.md" }]);
});
it("has no Context files without a workspace", () => {
renderMenu(null);
expect(screen.queryByTestId("chat-add-context")).toBeNull();
});
it("lists recent uploads newest first and attaches one as it was sent", () => {
rememberUploads([{ path: "/tmp/d/old.pdf", name: "old.pdf", image: false }]);
rememberUploads([{ path: "/tmp/d/0123456789abcdef.png", name: "shot.png", image: true }]);
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-recent"));
const rows = screen.getAllByTestId(/^chat-add-recent-/);
expect(rows.map((r) => r.textContent)).toEqual(["shot.png", "old.pdf"]);
fireEvent.click(rows[0]!);
expect(onAttach).toHaveBeenCalledWith([
{
path: "/tmp/d/0123456789abcdef.png",
name: "shot.png",
url: "/api/run/drop/0123456789abcdef.png",
},
]);
});
});
@@ -0,0 +1,78 @@
/**
* @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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
gitFile: vi.fn().mockResolvedValue({
available: true,
path: "src/a.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["export const a = 1;"],
}),
gitRawUrl: () => "",
},
ccConfig: {
file: vi
.fn()
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
},
},
};
});
const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
describe("FilePreview", () => {
it("reads a workspace file through the lane", async () => {
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
});
it("reads a memory file through the config read", async () => {
const mem = "/h/.claude/projects/-w/memory/launch.md";
render(<FilePreview path={mem} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("Launch notes")).toBeTruthy();
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={() => {}} />);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
});
describe("FileListModal", () => {
it("previews the file picked in the list", async () => {
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
expect(displayName(drop)).toBe("minutes.xlsx");
render(
<FileListModal
title="Uploads"
paths={[drop, "/w/src/a.ts"]}
scope={scope}
onClose={() => {}}
/>
);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
});
});
@@ -0,0 +1,87 @@
/**
* @file ChatOutline.test.tsx
* @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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { ChatOutline } from "../ChatOutline";
const outline = {
progress: [
{ id: "1", subject: "Parse plan", status: "completed" },
{ id: "2", subject: "Build report", status: "in_progress" },
],
outputs: ["/w/out/Progress report.xlsx"],
files: ["/w/src/a.ts", "/w/src/b.ts", "/w/src/c.ts"],
uploads: ["/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx"],
memory: ["/h/.claude/projects/-w/memory/release.md"],
skills: ["xlsx"],
mcp: ["vercel_vercel"],
};
describe("ChatOutline", () => {
it("shows progress, outputs and what the session used", () => {
const onClose = vi.fn();
const onOpenFile = vi.fn();
const onOpenList = vi.fn();
render(
<MemoryRouter>
<ChatOutline
outline={outline}
onClose={onClose}
onOpenFile={onOpenFile}
onOpenList={onOpenList}
/>
</MemoryRouter>
);
expect(screen.getByText("Parse plan").className).toContain("line-through");
expect(screen.getByText("Build report").className).not.toContain("line-through");
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
// An upload is named without the drop dir's random prefix.
fireEvent.click(screen.getByTitle("Uploads: minutes.xlsx"));
expect(onOpenList).toHaveBeenCalledWith("uploads");
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"
);
expect(screen.getByText("vercel_vercel")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
expect(onClose).toHaveBeenCalled();
});
it("hides progress and the used section when there is none", () => {
render(
<MemoryRouter>
<ChatOutline
outline={{
...outline,
progress: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
}}
onClose={() => {}}
onOpenFile={() => {}}
onOpenList={() => {}}
/>
</MemoryRouter>
);
expect(screen.queryByText("Progress")).toBeNull();
expect(screen.queryByText("Used in this session")).toBeNull();
expect(screen.getByRole("button", { name: "Hide side panel" })).toBeTruthy();
});
});
@@ -7,13 +7,13 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools";
import { __resetChatUi } from "../../../lib/chatUi";
import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -33,7 +33,10 @@ vi.mock("../PaneChat", () => ({
vi.mock("../../../lib/api", () => ({
api: {
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
me: {
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
savePrefs: vi.fn(),
},
lanes: {
ensure: vi.fn(),
action: vi.fn(),
@@ -171,7 +174,41 @@ describe("LaneConsolePane", () => {
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
__resetChatUi();
});
it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => {
vi.mocked(api.me.prefs).mockResolvedValueOnce({
prefs: { "agenthub.chatUi": "1" },
onboardedAt: null,
});
__resetChatUi();
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-1",
startedAt: null,
promptPreview: null,
tool: "claude" as const,
};
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toBeInTheDocument();
// Seen once, the session keeps chat: turning the default off leaves it alone.
await waitFor(() =>
expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true })
);
vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null });
await act(() => saveChatUi(false));
expect(screen.getByTestId("pane-chat")).toBeInTheDocument();
// Its own toggle switches it.
act(() => setSessionChat("s-1", false));
expect(screen.queryByTestId("pane-chat")).toBeNull();
__resetChatUi();
});
@@ -0,0 +1,274 @@
/**
* @file PaneChat.test.tsx
* @description The Chat view's controls: the `+` menu (Upload, Skills, Plugins
* → a plugin's skills, each inserted as `/<name>`), the Model/Effort chip typed
* into the pane as `/model` / `/effort`, and the permission mode read off the
* pane and stepped by the server — none of them for a Codex pane; and the
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat } from "../PaneChat";
import { api } from "../../../lib/api";
import { interruptRun, submitToRun } 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 }) => (
<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")} />
</div>
),
}));
vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
agents: { list: r({ agents: [] }) },
aiTools: {
models: r({
flag: true,
models: [
{ id: "opus", label: "Opus" },
{ id: "sonnet", label: "Sonnet" },
],
defaultModel: null,
}),
},
run: {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
permissionMode: r({ mode: "acceptEdits" }),
setPermissionMode: r({ mode: "plan" }),
},
sessions: {
outline: r({
progress: [],
outputs: ["/w/notes.md", "/w/report.xlsx"],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
}),
},
lanes: {
gitFile: r({
available: true,
path: "notes.md",
rev: "work",
binary: false,
truncated: false,
lines: ["# Notes", "", "hello"],
}),
gitRawUrl: () => "",
},
ccConfig: {
commands: r({ items: [] }),
skills: r({
items: [{ scope: "user", name: "morning", frontmatter: { description: "Plan the day" } }],
}),
plugins: r({
plugins: [
{
name: "doox",
enabled: true,
contributes: {
items: {
skills: [{ name: "plan-consolidation", description: "Merge plans" }],
commands: [],
},
},
},
{
name: "off",
enabled: false,
contributes: { items: { skills: [{ name: "x" }], commands: [] } },
},
],
}),
},
},
};
});
const RUN = {
id: "agenthub-lane-1",
tool: "claude" as "claude" | "codex",
model: "opus",
effort: "high" as const,
permissionMode: "auto" as const,
};
function renderChat(run: Partial<typeof RUN> = {}) {
return render(
<MemoryRouter>
<PaneChat run={{ ...RUN, ...run }} sessionId="s1" cwd="/w" />
</MemoryRouter>
);
}
beforeEach(() => vi.mocked(submitToRun).mockClear());
describe("PaneChat — the + menu", () => {
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
renderChat();
const box = screen.getByPlaceholderText("Write a message…") as HTMLTextAreaElement;
fireEvent.change(box, { target: { value: "today" } });
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-skills"));
fireEvent.click(await screen.findByTestId("chat-add-skill-morning"));
expect(box.value).toBe("/morning today");
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-plugins"));
expect(screen.queryByTestId("chat-add-plugin-off")).toBeNull(); // disabled plugin
fireEvent.click(await screen.findByTestId("chat-add-plugin-doox"));
fireEvent.click(screen.getByTestId("chat-add-skill-doox:plan-consolidation"));
expect(box.value).toBe("/doox:plan-consolidation /morning today");
});
it("Upload opens the prompt box's file picker", () => {
renderChat();
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
const clicked = vi.spyOn(input, "click");
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-upload"));
expect(clicked).toHaveBeenCalled();
});
});
describe("PaneChat — Stop", () => {
it("replaces Send while the main agent works and the box is empty, and presses Esc", async () => {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "working" }],
} as never);
renderChat();
fireEvent.click(await screen.findByTestId("pane-chat-stop"));
expect(interruptRun).toHaveBeenCalledWith("agenthub-lane-1");
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
expect(screen.getByTestId("pane-chat-send")).toBeInTheDocument();
});
it("drops Stop once the turn ended even when no live update says so", async () => {
// A missed agent_updated (another dashboard on the same database got the
// hook, or the socket dropped): the poll while working catches it.
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "working" }],
} as never);
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
}
});
it("keeps Send for a session at rest", async () => {
// With session_id the server answers EVERY agent of the session (its
// status filter does not apply): the main agent is there, waiting.
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "waiting" }],
} as never);
renderChat();
await waitFor(() => expect(api.agents.list).toHaveBeenCalled());
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
});
});
describe("PaneChat — model, effort and permission mode", () => {
it("types /model into the pane", async () => {
renderChat();
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
});
it("shows the mode the pane shows, and asks the server to step it", async () => {
renderChat();
const chip = byId("pane-chat-mode");
await waitFor(() => expect(chip()).toHaveTextContent("Accept edits"));
fireEvent.click(chip().querySelector("button")!);
fireEvent.click(await screen.findByRole("option", { name: /Plan/ }));
await waitFor(() =>
expect(api.run.setPermissionMode).toHaveBeenCalledWith("agenthub-lane-1", "plan")
);
await waitFor(() => expect(chip()).toHaveTextContent("Plan"));
});
it("a Codex pane gets the + menu's Upload only — no skills, model or mode", () => {
renderChat({ tool: "codex" });
expect(screen.queryByTestId("pane-chat-mode")).toBeNull();
expect(screen.queryByRole("button", { name: /Opus/ })).toBeNull();
fireEvent.click(screen.getByTestId("chat-add"));
expect(screen.getByTestId("chat-add-upload")).toBeTruthy();
expect(screen.queryByTestId("chat-add-skills")).toBeNull();
});
});
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 />
</MemoryRouter>
);
expect(screen.queryByTestId("chat-outline")).toBeNull();
const show = screen.getByTestId("chat-outline-show");
await waitFor(() => expect(show.textContent).toBe("2"));
fireEvent.click(show);
fireEvent.click(screen.getByRole("button", { name: "MD: notes" }));
expect(screen.queryByTestId("chat-outline")).toBeNull();
await screen.findByTestId("markdown-preview");
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(7, "notes.md");
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.getByTestId("chat-outline")).toBeTruthy();
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
});
});
describe("PaneChat — an edited file", () => {
it("opens in the Changes panel when it is in the pane's folder, else in its own preview", async () => {
const seen: unknown[] = [];
const listen = (e: Event) => seen.push((e as CustomEvent).detail);
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
</MemoryRouter>
);
fireEvent.click(screen.getByTestId("edited-in"));
expect(seen).toEqual([{ laneId: 7, path: "src/a.ts" }]);
expect(screen.queryByTestId("chat-file-preview")).toBeNull();
// Outside the folder the Changes panel cannot show it: the chat's preview does.
fireEvent.click(screen.getByTestId("edited-out"));
expect(seen).toHaveLength(1);
expect(await screen.findByTestId("chat-file-preview")).toBeInTheDocument();
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
});
});
@@ -3,7 +3,10 @@
* @description The run-form pieces that outlived the pane's run form:
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
* 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
* (`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
* same list as one chip: aliases up front, pinned versions under More models,
@@ -13,17 +16,19 @@
*/
import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import {
ModelMenu,
ModelPicker,
PASTE_AS_FILE_CHARS,
PromptEditor,
promptWithImages,
type PromptImage,
} from "../RunSetup";
import { api } from "../../../lib/api";
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -158,6 +163,34 @@ describe("PromptEditor — / and @ suggestions", () => {
});
});
describe("PromptEditor — a composer grows with its text", () => {
it("takes the height its text needs, capped by max-h, and only when bare", () => {
const tall = vi
.spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
.mockReturnValue(180);
const props = {
placeholder: "Ask anything…",
slash: false,
fileCwd: "/Users/tester",
images: [],
onImagesChange: () => {},
onChange: () => {},
onSubmit: () => {},
};
const view = render(<PromptEditor {...props} value={"a\nb\nc\nd\ne"} bare />);
expect(box().style.height).toBe("180px");
expect(box().className).toContain("max-h-[40vh]");
tall.mockReturnValue(60);
view.rerender(<PromptEditor {...props} value="a" bare />);
expect(box().style.height).toBe("60px");
view.unmount();
// The framed editor keeps its fixed rows.
render(<PromptEditor {...props} value={"a\nb\nc"} />);
expect(box().style.height).toBe("");
tall.mockRestore();
});
});
describe("PromptEditor — newlines and images", () => {
it("Alt+Enter inserts a newline and does not send", () => {
const spies = renderEditor("ab");
@@ -186,6 +219,7 @@ describe("PromptEditor — newlines and images", () => {
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
lane={7}
/>
<output data-testid="sent">{promptWithImages("see", images)}</output>
</>
@@ -195,12 +229,14 @@ describe("PromptEditor — newlines and images", () => {
const img = new File(["x"], "shot.png", { type: "image/png" });
fireEvent.paste(box(), { clipboardData: { files: [img] } });
const thumb = await screen.findByRole("button", { name: "shot.png" });
expect(api.run.dropFile).toHaveBeenCalledWith(img);
// Saved where workspace 7's panes can read it.
expect(api.run.dropFile).toHaveBeenCalledWith(img, 7);
// The path is not typed into the prompt; it goes with it on send.
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
fireEvent.click(thumb);
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
// On the page, not in the composer's layer: nothing stacked over a pane covers it.
expect(screen.getByRole("dialog", { name: "shot.png" }).parentElement).toBe(document.body);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
@@ -210,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("see");
});
it("a composer's Upload opens a picker whose files attach like a paste", async () => {
const pick: { current: (() => void) | null } = { current: null };
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
pickFiles={pick}
/>
<output data-testid="sent">{promptWithImages("", images)}</output>
</>
);
}
render(<Harness />);
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
const clicked = vi.spyOn(input, "click");
pick.current?.();
expect(clicked).toHaveBeenCalled();
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
fireEvent.change(input, { target: { files: [doc] } });
expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined); // no workspace named
expect(await screen.findByText("spec.pdf")).toBeTruthy();
});
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
function Harness() {
@@ -235,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
expect(await screen.findByText("spec.pdf")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
// Remembered for the + menu's Recent uploads.
expect(recentUploads()).toContainEqual({
path: "/tmp/agenthub-drops-1/b-spec.pdf",
name: "spec.pdf",
});
});
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<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: [],
};
// What a Files panel row's onDragStart puts on the drag.
dragProjectPath({ dataTransfer } as unknown as React.DragEvent, "src/lib/");
fireEvent.drop(box(), { dataTransfer });
fireEvent.drop(box(), { dataTransfer });
expect(screen.getByText("lib/")).toBeTruthy();
expect(api.run.dropFile).not.toHaveBeenCalled();
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), { clipboardData: { files: [] } });
fireEvent.paste(box(), {
clipboardData: { files: [], getData: () => "a short note" },
});
expect(api.run.dropFile).not.toHaveBeenCalled();
});
it("attaches a very long text paste as a text file, like a pasted file (#68)", async () => {
renderEditor("");
const long = "x".repeat(PASTE_AS_FILE_CHARS + 1);
fireEvent.paste(box(), { clipboardData: { files: [], getData: () => long } });
await waitFor(() => expect(api.run.dropFile).toHaveBeenCalledTimes(1));
const file = vi.mocked(api.run.dropFile).mock.calls[0]![0] as File;
expect(file.name).toBe("pasted-text.txt");
expect(file.type).toBe("text/plain");
expect(file.size).toBe(long.length); // the whole paste, nothing cut
expect(box()).toHaveValue(""); // not typed into the box
});
});
describe("ModelPicker", () => {
@@ -23,7 +23,7 @@ const focusMock = vi.fn();
const pasteMock = vi.fn();
const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
@@ -481,7 +481,8 @@ describe("TerminalView", () => {
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img);
// The pane's run id: saved where that pane can read it.
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
@@ -1,15 +1,16 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
* @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
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), 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
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
* **Run setup again** reopens the first-run wizard (`components/onboarding`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,8 +20,10 @@ import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { PetSettings } from "./PetSettings";
import { ChatUiSettings } from "./ChatUiSettings";
import { Select } from "../Select";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { resetOnboarding } from "../../lib/userPrefs";
import { toast } from "../../lib/toast";
import {
TERMINAL_FONTS,
@@ -29,7 +32,7 @@ import {
type TerminalFont,
} from "../../lib/terminalFont";
const THEME_LABELS: Record<string, string> = {
export const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
"apple-dark": "Apple Dark",
"apple-light": "Apple Light",
@@ -44,7 +47,7 @@ const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
{ mode: "system", Icon: Monitor },
];
function Swatch({ mode, name }: { mode: Theme; name: string }) {
export function Swatch({ mode, name }: { mode: Theme; name: string }) {
return (
<div
aria-hidden
@@ -96,6 +99,8 @@ export function AppearanceSettings() {
return (
<div className="space-y-6">
<ChatUiSettings />
<SettingsSection
id="language"
title={t("language.title")}
@@ -126,8 +131,9 @@ export function AppearanceSettings() {
label={t("terminalFont.label")}
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
control={
<SettingsSegmented
ariaLabel={t("terminalFont.title")}
// A dropdown: five font names overflow a segmented row.
<Select<TerminalFont>
label={t("terminalFont.title")}
value={termFont}
onChange={pickTermFont}
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
@@ -148,8 +154,6 @@ export function AppearanceSettings() {
</p>
</SettingsSection>
<ChatUiSettings />
<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
@@ -221,6 +225,26 @@ export function AppearanceSettings() {
</div>
<PetSettings />
</section>
<SettingsSection
id="setup"
title={t("onboarding:rerun.label")}
description={t("onboarding:rerun.description")}
>
<SettingsRow
label={t("onboarding:rerun.label")}
control={
<button
type="button"
onClick={resetOnboarding}
data-testid="rerun-setup"
className="btn-ghost border border-border text-xs"
>
{t("onboarding:rerun.button")}
</button>
}
/>
</SettingsSection>
</div>
);
}
@@ -88,16 +88,17 @@ describe("AppearanceSettings", () => {
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
"aria-checked",
"true"
);
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
const trigger = screen.getByRole("button", { name: /Terminal font/ });
expect(trigger).toHaveTextContent("JetBrains Mono");
const pick = async (name: string) => {
await user.click(screen.getByRole("button", { name: /Terminal font/ }));
await user.click(screen.getByRole("option", { name }));
};
await pick("Maple Mono (rounded)");
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
await user.click(within(group).getByRole("radio", { name: "System font" }));
await pick("System font");
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
});
+4 -3
View File
@@ -7,7 +7,8 @@
* frames missed while disconnected are never replayed. The copy is dropped
* when the last component unmounts, so a fresh mount always reads fresh rows.
* `useWorkLanes()` is the same list narrowed to the workspaces this user may
* work in (server mode: an analyst's own), for the Workspace page and tree.
* work in (server mode: an analyst's own), for the Workspace page; the sidebar
* tree lists them all, other users' read only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -97,10 +98,10 @@ export function useLanes() {
}
/**
* The workspaces this user WORKS in — the Workspace page and its sidebar tree.
* The workspaces this user WORKS in — the Workspace page.
* Server mode: an analyst gets every workspace from the API (to read) but may
* change only their own, so only those are listed here; other users' work is
* read on the Sessions, Analytics and Users pages.
* read in the sidebar tree (read only), and on Sessions, Analytics and Users.
*/
export function useWorkLanes() {
const all = useLanes();
+25 -3
View File
@@ -7,11 +7,15 @@
* themselves are CSS-variable blocks in `index.css`. State is never cached in
* the module — every instance reads storage + `matchMedia` through
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
* another tab's change arrives through the `storage` event.
* another tab's change arrives through the `storage` event. Every change is also
* saved to the account (`/api/me/prefs`, same keys); a sign-in loads it back
* through `lib/userPrefs.ts` (`hydrate`), while the login page wears the OS's
* light/dark (`applySystemTheme`) because nobody has chosen yet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
import { api } from "../lib/api";
export type Theme = "dark" | "light";
export type ThemeMode = Theme | "system";
@@ -19,7 +23,7 @@ export type ThemeMode = Theme | "system";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark", "orca-dark"],
light: ["default", "apple-light", "canvas", "orca-light"],
light: ["default", "canvas", "apple-light", "orca-light"],
} as const satisfies Record<Theme, readonly string[]>;
const MODE_KEY = "theme";
@@ -35,13 +39,20 @@ function read(key: string): string | null {
}
}
function write(key: string, value: string): void {
function store(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* ignore quota / disabled storage */
}
}
/** A user's own choice: this browser, every reader, and the account. */
function write(key: string, value: string): void {
store(key, value);
window.dispatchEvent(new Event(CHANGE_EVENT));
// This browser keeps it even when the save fails (offline, signed out).
api.me.savePrefs({ [key]: value }).catch(() => {});
}
function systemQuery(): MediaQueryList | undefined {
@@ -86,6 +97,17 @@ function apply(resolved: Theme, name: string): void {
else root.dataset.theme = name;
}
/** Paint what this browser stores (index.html's job at page load). */
export function applyStoredTheme(): void {
const [, resolved, dark, light] = getSnapshot().split("|") as [ThemeMode, Theme, string, string];
apply(resolved, resolved === "dark" ? dark : light);
}
/** The login page: the OS's light/dark in the stock palette. */
export function applySystemTheme(): void {
apply(systemQuery()?.matches === false ? "light" : "dark", "default");
}
/**
* Read/write the dashboard's appearance.
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
+1
View File
@@ -115,6 +115,7 @@ export const i18nReady = i18n
"splash",
"lanes",
"users",
"onboarding",
],
defaultNS: "common",
interpolation: { escapeValue: false },
+5 -1
View File
@@ -186,14 +186,18 @@
"keep": "Keep editing"
},
"auth": {
"title": "Sign in to AgentHub",
"title": "Welcome back",
"subtitle": "Sign in to AgentHub with your account",
"username": "Username",
"password": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submit": "Sign in",
"submitting": "Signing in…",
"failed": "Wrong username or password",
"tooMany": "Too many failed attempts — try again in a few minutes",
"logout": "Sign out",
"noAccount": "No account yet? Ask your administrator to create one.",
"signedInAs": "Signed in as {{name}} ({{role}})"
},
"insights": {
+10 -5
View File
@@ -48,6 +48,8 @@
"changes.bin": "bin",
"changes.binary": "Binary file — not shown.",
"changes.collapse": "Collapse the changes panel",
"changes.closeRepo": "Close repository",
"changes.closeOtherRepos": "Close other repositories",
"changes.collapseAll": "Collapse every file",
"changes.collapseAllFolders": "Collapse all folders",
"changes.collapseContext": "Show fewer surrounding lines",
@@ -91,10 +93,8 @@
"changes.range.work": "Uncommitted",
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
"changes.refresh": "Refresh",
"changes.resize": "Resize the changes panel (next: {{size}})",
"changes.size.compact": "Narrow",
"changes.size.half": "Medium",
"changes.size.full": "Full screen",
"changes.restore": "Restore size",
"changes.fileCount_one": "{{count}} file",
"changes.fileCount_other": "{{count}} files",
"changes.dropEdits": "Discard your unsaved edits to this file?",
@@ -102,6 +102,7 @@
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
"changes.unavailable": "This workspace's directory is not a readable git repository.",
"changes.unavailableFolder": "This folder is not a readable git repository.",
"changes.unstage": "Unstage this file",
"changes.changesView": "Changes",
"changes.wholeFile": "Whole file",
@@ -134,8 +135,6 @@
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
"destructive.reset.title": "Reset managed worktree?",
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
"empty.body": "A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
"empty.title": "No workspaces yet",
"features.archived": "archived",
"features.live": "Live",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the workspace keeps running; this is a read-only snapshot.",
@@ -316,6 +315,10 @@
"tree.search": "Search workspaces and sessions",
"tree.toolFilter": "Filter by tool",
"tree.allTools": "All tools",
"tree.ownerYou": "{{name}} (you)",
"tree.noOwner": "No owner",
"tree.ownerToggle": "Show or hide {{name}}'s workspaces",
"tree.readOnly": "{{name}}'s workspace — read only. A session opens its analysis page.",
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
@@ -389,6 +392,8 @@
"panes.split.bottom": "Split down",
"panes.full": "A tab holds at most 4 panes",
"panes.newTerminal": "New terminal",
"panes.showChat": "Show as chat",
"panes.showTerminal": "Show the terminal",
"panes.close": "Move to its own tab (the session keeps running)",
"panes.resize": "Resize panes",
"tabs.home": "Workspace home",
@@ -0,0 +1,95 @@
{
"brand": "AgentHub",
"progress": "{{n}} of {{total}}",
"skip": "Skip setup",
"back": "Back",
"continue": "Continue",
"finish": "Add your first workspace",
"agent": {
"eyebrow": "Welcome to AgentHub",
"title": "Pick your default agent",
"body": "AgentHub works with every CLI agent. Choose the one you'll reach for most. Switch any time.",
"detected": "Detected on this machine · {{count}}",
"none": "No AI tool is installed yet. An admin installs them in Settings → AI Tools; you can finish setup and pick one later.",
"more": "Show {{count}} more agents",
"less": "Show fewer",
"notInstalled": "not installed",
"permission": "Start new sessions in",
"permissionHint": "Bypass permissions is never a default: it stays a red switch you turn on for one session.",
"modes": {
"auto": "Auto",
"acceptEdits": "Accept edits",
"default": "Ask first",
"plan": "Plan"
}
},
"look": {
"title": "Make it feel like home",
"body": "Pick the look you want to stare at for hours.",
"modes": {
"system": "System",
"dark": "Dark",
"light": "Light"
},
"modeHints": {
"system": "Match OS",
"dark": "Easy on the eyes",
"light": "Bright & crisp"
},
"darkThemes": "Dark theme",
"lightThemes": "Light theme",
"language": "Language",
"font": "Terminal font",
"more": "More options — themes, fonts, the pet — in Settings → Appearance."
},
"pet": {
"title": "Pick a companion",
"body": "A small pet sits in the corner and reacts to what your agents do. Hide it any time."
},
"notify": {
"title": "Set up notifications",
"body": "AgentHub tells you when agents are done or need you.",
"enable": "Desktop notifications",
"enableHint": "Your browser asks for permission once.",
"blocked": "Notifications are blocked for this site — allow them in the browser's site settings.",
"sound": "Notification sound",
"preview": "Play",
"test": "Send test notification"
},
"tour": {
"progress": "Step {{n}} of {{total}}",
"skip": "Skip tour",
"next": "Next",
"done": "Done",
"goWorkspaces": "Go to Workspaces",
"add": {
"title": "Create your first workspace",
"body": "A workspace is one project folder where your agents work. Click + beside Workspaces in the sidebar to add one."
},
"folder": {
"title": "Choose the project folder",
"body": "Type a path or Browse to the folder of your project. A git repository gets a Changes panel too."
},
"create": {
"title": "Create it",
"body": "Pick the AI it starts on, then click Add workspace."
},
"prompt": {
"title": "Start your first session",
"body": "Tell the agent what to do and press Enter. AI, model and permission mode sit right here."
},
"session": {
"title": "Your session is live",
"body": "Watch it work and type into it like a terminal. Changes and Files are in the panel on the right; every session of this workspace is listed in the sidebar."
},
"finish": {
"title": "You're all set",
"body": "Run setup again or change any of it in Settings."
}
},
"rerun": {
"label": "First-run setup",
"description": "Go through the welcome steps and the tour again.",
"button": "Run setup again"
}
}
+33 -2
View File
@@ -85,7 +85,38 @@
"chat": {
"placeholder": "Write a message…",
"send": "Send",
"hint": "Enter sends · permission prompts and menus need the terminal (Settings → Appearance → Chat UI)",
"notSent": "The terminal is not connected — the message was not sent."
"notSent": "The terminal is not connected — the message was not sent.",
"stop": "Stop",
"notStopped": "The terminal is not connected — nothing was stopped.",
"add": "Add files, skills…",
"upload": "Upload or add files",
"projectFiles": "Project files",
"contextFiles": "Context files",
"recentUploads": "Recent uploads",
"searchFiles": "Search files and folders…",
"skills": "Skills",
"plugins": "Plugins",
"manageSkills": "Manage skills",
"managePlugins": "Manage plugins",
"none": "Nothing here yet",
"fromPlugin": "{{name}} plugin",
"modeFailed": "Could not change the permission mode: {{message}}",
"outline": {
"progress": "Progress",
"outputs": "Outputs",
"connectors": "Connectors",
"empty": "Nothing yet",
"hide": "Hide side panel",
"show": "Activity panel",
"copyPath": "Copy path: {{path}}",
"panel": "Session activity panel",
"used": "Used in this session",
"uploads": "Uploads",
"files": "Files read",
"memory": "Memory",
"close": "Close",
"noPreview": "No preview for this file here",
"truncated": "Showing the first part of this file"
}
}
}
+4 -1
View File
@@ -196,7 +196,10 @@
"agents_other": "ran {{count}} agents",
"tools_one": "used a tool",
"tools_other": "used {{count}} tools"
}
},
"edited_one": "Edited {{count}} file",
"edited_other": "Edited {{count}} files",
"working": "Thinking…"
},
"overview": {
"agent": "Agent",
+16 -10
View File
@@ -34,7 +34,7 @@
"bubbles": "Speech bubbles",
"bubblesDesc": "Let the pet comment on your sessions in a bubble",
"sizeDesc": "{{min}}–{{max}} px; the preview is capped at 120 px",
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved in this browser.",
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
"panel": {
"label": "Sagi companion",
@@ -189,7 +189,7 @@
"alert": "Alert",
"forType": "Sound for this type"
},
"scopeNote": "These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
"scopeNote": "These notification settings are saved to your account. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
"insecureOrigin": "This page is served from {{origin}}, which is not a secure origin. Browsers only show notifications on HTTPS or localhost — open the dashboard through one of those to get them.",
"unsupported": "This browser does not support notifications.",
"deniedHelp": "Notifications are blocked for this site. To allow them again, open the site settings (the icon left of the address bar), set Notifications to Allow, then reload this page.",
@@ -309,6 +309,7 @@
"label": "Start AgentHub automatically",
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
"desc_systemd-system": "Run the dashboard as the system service agenthub.service (server mode, one Linux user per account), so it starts with the machine.",
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
},
@@ -662,7 +663,12 @@
"test": "Test",
"testing": "Testing…",
"testOk": "answered — {{model}} is reachable with this key.",
"testFailed": "no answer ({{status}}): {{message}}"
"testFailed": "no answer ({{status}}): {{message}}",
"personal": "Personal",
"fieldScope": "Who can use it",
"scopeShared": "Everyone (shared)",
"scopePersonal": "Only me (personal)",
"fieldScopeHint": "A shared profile's key never reaches anyone's browser. A personal one is listed, edited and used by you alone."
},
"aiTools": {
"title": "AI Tools",
@@ -697,7 +703,7 @@
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only.",
"description": "Colour mode and theme. Saved to your account, so every browser you sign in to gets them.",
"mode": "Mode",
"modes": {
"dark": "Dark",
@@ -882,7 +888,7 @@
},
"language": {
"title": "Language",
"description": "The language of the whole dashboard, remembered in this browser.",
"description": "The language of the whole dashboard, saved to your account.",
"label": "Interface language",
"names": {
"en": "English",
@@ -891,7 +897,7 @@
},
"terminalFont": {
"title": "Terminal font",
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved in this browser only.",
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
"label": "Font",
"names": {
"jetbrains": "JetBrains Mono",
@@ -1001,7 +1007,7 @@
},
"prefsBackup": {
"title": "This browser's preferences",
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
"description": "Theme, language, notification settings and pet follow your account; the sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
"export": "Export preferences",
"import": "Import preferences…",
"importTitle": "Import preferences?",
@@ -1016,9 +1022,9 @@
},
"chatUi": {
"title": "Chat UI",
"description": "How a Claude or Codex session opens in its workspace. Saved for your account only.",
"label": "Open sessions as a chat",
"labelDesc": "Show every Claude or Codex session's conversation like Claude Desktop, with a message box, instead of its terminal. Permission prompts and menus still need the terminal: turn this off to answer them.",
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
"label": "Open new sessions as a chat",
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
"saveFailed": "Could not save: {{message}}"
}
}
+3 -1
View File
@@ -69,5 +69,7 @@
"readOnly": "Analysts can view accounts but not change them.",
"auditTitle": "Audit log",
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
"auditEmpty": "Nothing recorded yet."
"auditEmpty": "Nothing recorded yet.",
"activeCount": "{{n}} active",
"cost30d": "Cost · 30 days"
}
+5 -1
View File
@@ -186,14 +186,18 @@
"keep": "Tiếp tục sửa"
},
"auth": {
"title": "Đăng nhập AgentHub",
"title": "Chào mừng trở lại",
"subtitle": "Đăng nhập AgentHub bằng tài khoản của bạn",
"username": "Tên đăng nhập",
"password": "Mật khẩu",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"submit": "Đăng nhập",
"submitting": "Đang đăng nhập…",
"failed": "Sai tên đăng nhập hoặc mật khẩu",
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
"logout": "Đăng xuất",
"noAccount": "Chưa có tài khoản? Hãy nhờ quản trị viên tạo cho bạn.",
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
},
"insights": {
+10 -5
View File
@@ -48,6 +48,8 @@
"changes.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.",
"changes.collapse": "Thu gọn bảng thay đổi",
"changes.closeRepo": "Đóng repository",
"changes.closeOtherRepos": "Đóng các repository khác",
"changes.collapseAll": "Thu gọn tất cả file",
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
"changes.collapseContext": "Bớt dòng xung quanh",
@@ -91,10 +93,8 @@
"changes.range.work": "Chưa commit",
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
"changes.refresh": "Tải lại",
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
"changes.size.compact": "Hẹp",
"changes.size.half": "Vừa",
"changes.size.full": "Toàn màn hình",
"changes.restore": "Thu nhỏ",
"changes.fileCount_one": "{{count}} tệp",
"changes.fileCount_other": "{{count}} tệp",
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
@@ -102,6 +102,7 @@
"changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
"changes.unavailableFolder": "Thư mục này không phải repo git đọc được.",
"changes.unstage": "Bỏ file này khỏi index",
"changes.changesView": "Thay đổi",
"changes.wholeFile": "Toàn bộ file",
@@ -134,8 +135,6 @@
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
"destructive.reset.title": "Đặt lại worktree được quản lý?",
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
"empty.body": "Mỗi workspace là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
"empty.title": "Chưa có workspace nào",
"features.archived": "đã lưu trữ",
"features.live": "Phiên bản trực tiếp",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — workspace tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
@@ -316,6 +315,10 @@
"tree.search": "Tìm workspace và session",
"tree.toolFilter": "Lọc theo tool",
"tree.allTools": "Mọi tool",
"tree.ownerYou": "{{name}} (bạn)",
"tree.noOwner": "Không có chủ",
"tree.ownerToggle": "Hiện hoặc ẩn workspace của {{name}}",
"tree.readOnly": "Workspace của {{name}} — chỉ xem. Bấm một phiên để mở trang phân tích.",
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
@@ -389,6 +392,8 @@
"panes.split.bottom": "Tách xuống dưới",
"panes.full": "Một tab chứa tối đa 4 pane",
"panes.newTerminal": "Terminal mới",
"panes.showChat": "Xem dạng chat",
"panes.showTerminal": "Xem dạng terminal",
"panes.close": "Tách ra tab riêng (session vẫn chạy)",
"panes.resize": "Đổi kích thước pane",
"tabs.home": "Trang chủ workspace",
@@ -0,0 +1,95 @@
{
"brand": "AgentHub",
"progress": "{{n}} / {{total}}",
"skip": "Bỏ qua thiết lập",
"back": "Quay lại",
"continue": "Tiếp tục",
"finish": "Thêm workspace đầu tiên",
"agent": {
"eyebrow": "Chào mừng đến AgentHub",
"title": "Chọn agent mặc định",
"body": "AgentHub chạy được mọi CLI agent. Chọn cái bạn dùng nhiều nhất — đổi lúc nào cũng được.",
"detected": "Đã có trên máy này · {{count}}",
"none": "Chưa có AI tool nào được cài. Admin cài trong Cài đặt → AI Tools; bạn có thể hoàn tất thiết lập và chọn sau.",
"more": "Xem thêm {{count}} agent",
"less": "Thu gọn",
"notInstalled": "chưa cài",
"permission": "Session mới bắt đầu ở chế độ",
"permissionHint": "Bypass permissions không bao giờ là mặc định: nó vẫn là công tắc đỏ bạn bật cho từng session.",
"modes": {
"auto": "Tự động",
"acceptEdits": "Nhận sửa đổi",
"default": "Hỏi trước",
"plan": "Lập kế hoạch"
}
},
"look": {
"title": "Làm cho nó thật quen thuộc",
"body": "Chọn giao diện bạn sẽ nhìn hàng giờ liền.",
"modes": {
"system": "Hệ thống",
"dark": "Tối",
"light": "Sáng"
},
"modeHints": {
"system": "Theo hệ điều hành",
"dark": "Dịu mắt",
"light": "Sáng rõ"
},
"darkThemes": "Theme tối",
"lightThemes": "Theme sáng",
"language": "Ngôn ngữ",
"font": "Font terminal",
"more": "Thêm tuỳ chọn — theme, font, thú cưng — trong Cài đặt → Giao diện."
},
"pet": {
"title": "Chọn một người bạn đồng hành",
"body": "Một thú cưng nhỏ ngồi ở góc màn hình và phản ứng với việc agent đang làm. Ẩn lúc nào cũng được."
},
"notify": {
"title": "Thiết lập thông báo",
"body": "AgentHub báo cho bạn khi agent xong việc hoặc cần bạn.",
"enable": "Thông báo trên máy",
"enableHint": "Trình duyệt sẽ hỏi quyền một lần.",
"blocked": "Trang này đang bị chặn thông báo — cho phép trong cài đặt trang của trình duyệt.",
"sound": "Âm thanh thông báo",
"preview": "Nghe thử",
"test": "Gửi thông báo thử"
},
"tour": {
"progress": "Bước {{n}} / {{total}}",
"skip": "Bỏ qua hướng dẫn",
"next": "Tiếp",
"done": "Xong",
"goWorkspaces": "Mở Workspaces",
"add": {
"title": "Tạo workspace đầu tiên",
"body": "Workspace là một thư mục dự án nơi các agent làm việc. Bấm dấu + cạnh mục Không gian làm việc ở sidebar để thêm."
},
"folder": {
"title": "Chọn thư mục dự án",
"body": "Gõ đường dẫn hoặc bấm Browse tới thư mục dự án. Repo git sẽ có thêm bảng Changes."
},
"create": {
"title": "Tạo workspace",
"body": "Chọn AI sẽ dùng, rồi bấm Thêm workspace."
},
"prompt": {
"title": "Bắt đầu session đầu tiên",
"body": "Viết việc cần agent làm rồi nhấn Enter. Chọn AI, model và chế độ quyền ngay tại đây."
},
"session": {
"title": "Session đang chạy",
"body": "Xem agent làm việc và gõ vào như một terminal. Changes và Files nằm ở bảng bên phải; mọi session của workspace này có trong thanh bên."
},
"finish": {
"title": "Xong rồi!",
"body": "Chạy lại thiết lập hoặc đổi bất cứ lựa chọn nào trong Cài đặt."
}
},
"rerun": {
"label": "Thiết lập ban đầu",
"description": "Đi lại các bước chào mừng và phần hướng dẫn.",
"button": "Chạy lại thiết lập"
}
}
+33 -2
View File
@@ -85,7 +85,38 @@
"chat": {
"placeholder": "Viết tin nhắn…",
"send": "Gửi",
"hint": "Enter để gửi · hỏi quyền và menu cần terminal (Settings → Appearance → Giao diện chat)",
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi."
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
"stop": "Ngưng",
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
"add": "Thêm file, skill…",
"upload": "Tải lên hoặc thêm file",
"projectFiles": "File trong dự án",
"contextFiles": "File ngữ cảnh",
"recentUploads": "Đã tải lên gần đây",
"searchFiles": "Tìm file và thư mục…",
"skills": "Skills",
"plugins": "Plugins",
"manageSkills": "Quản lý skills",
"managePlugins": "Quản lý plugins",
"none": "Chưa có gì",
"fromPlugin": "Plugin {{name}}",
"modeFailed": "Không đổi được permission mode: {{message}}",
"outline": {
"progress": "Tiến độ",
"outputs": "Kết quả",
"connectors": "Kết nối",
"empty": "Chưa có gì",
"hide": "Ẩn thanh bên",
"show": "Bảng hoạt động",
"copyPath": "Sao chép đường dẫn: {{path}}",
"panel": "Bảng hoạt động của phiên",
"used": "Đã dùng trong phiên",
"uploads": "Tệp tải lên",
"files": "Tệp đã đọc",
"memory": "Bộ nhớ",
"close": "Đóng",
"noPreview": "Không xem trước được tệp này ở đây",
"truncated": "Chỉ hiện phần đầu của tệp"
}
}
}
+4 -1
View File
@@ -196,7 +196,10 @@
"agents_other": "chạy {{count}} agent",
"tools_one": "dùng 1 công cụ",
"tools_other": "dùng {{count}} công cụ"
}
},
"edited_one": "Đã sửa {{count}} tệp",
"edited_other": "Đã sửa {{count}} tệp",
"working": "Đang suy nghĩ…"
},
"overview": {
"agent": "Agent",
+16 -10
View File
@@ -34,7 +34,7 @@
"bubbles": "Bong bóng lời thoại",
"bubblesDesc": "Cho pet bình luận về các session của bạn trong bong bóng",
"sizeDesc": "{{min}}–{{max}} px; bản xem trước tối đa 120 px",
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu trong trình duyệt này.",
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
"panel": {
"label": "Trợ lý Sagi",
@@ -189,7 +189,7 @@
"alert": "Cảnh báo",
"forType": "Âm thanh cho loại này"
},
"scopeNote": "Các cài đặt thông báo này chỉ lưu trong trình duyệt này. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
"scopeNote": "Các cài đặt thông báo này được lưu vào tài khoản của bạn. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
"insecureOrigin": "Trang này được phục vụ từ {{origin}}, không phải nguồn bảo mật. Trình duyệt chỉ hiện thông báo trên HTTPS hoặc localhost — hãy mở bảng điều khiển qua một trong hai cách đó.",
"unsupported": "Trình duyệt này không hỗ trợ thông báo.",
"deniedHelp": "Thông báo đang bị chặn cho trang này. Để cho phép lại, mở cài đặt trang (biểu tượng bên trái thanh địa chỉ), đặt Thông báo thành Cho phép, rồi tải lại trang.",
@@ -309,6 +309,7 @@
"label": "Tự động khởi động AgentHub",
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
"desc_systemd-system": "Chạy dashboard bằng system service agenthub.service (chế độ server, mỗi tài khoản một Linux user) để nó tự bật cùng máy.",
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
},
@@ -662,7 +663,12 @@
"test": "Kiểm tra",
"testing": "Đang kiểm tra…",
"testOk": "đã trả lời — {{model}} dùng được với key này.",
"testFailed": "không trả lời ({{status}}): {{message}}"
"testFailed": "không trả lời ({{status}}): {{message}}",
"personal": "Riêng",
"fieldScope": "Ai được dùng",
"scopeShared": "Mọi người (dùng chung)",
"scopePersonal": "Chỉ tôi (riêng)",
"fieldScopeHint": "Key của profile dùng chung không bao giờ tới trình duyệt của ai. Profile riêng chỉ mình bạn thấy, sửa và dùng."
},
"aiTools": {
"title": "AI Tools",
@@ -697,7 +703,7 @@
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
"description": "Chế độ màu và theme. Lưu vào tài khoản, nên mọi trình duyệt bạn đăng nhập đều dùng chúng.",
"mode": "Chế độ",
"modes": {
"dark": "Tối",
@@ -882,7 +888,7 @@
},
"language": {
"title": "Ngôn ngữ",
"description": "Ngôn ngữ của toàn bộ dashboard, được ghi nhớ trong trình duyệt này.",
"description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.",
"label": "Ngôn ngữ giao diện",
"names": {
"en": "English",
@@ -891,7 +897,7 @@
},
"terminalFont": {
"title": "Font terminal",
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Chỉ lưu trong trình duyệt này.",
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
"label": "Font",
"names": {
"jetbrains": "JetBrains Mono",
@@ -1001,7 +1007,7 @@
},
"prefsBackup": {
"title": "Tùy chọn của trình duyệt này",
"description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
"description": "Giao diện, ngôn ngữ, cài đặt thông báo và thú cưng theo tài khoản của bạn; thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
"export": "Xuất tùy chọn",
"import": "Nhập tùy chọn…",
"importTitle": "Nhập tùy chọn?",
@@ -1016,9 +1022,9 @@
},
"chatUi": {
"title": "Giao diện chat",
"description": "Cách một session Claude hoặc Codex mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
"label": "Mở session dạng chat",
"labelDesc": "Hiện hội thoại của mọi session Claude hoặc Codex như Claude Desktop, kèm ô nhập tin, thay cho terminal. Hỏi quyền và menu vẫn cần terminal: tắt mục này để trả lời chúng.",
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
"label": "Mở session mới dạng chat",
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
"saveFailed": "Không lưu được: {{message}}"
}
}
+3 -1
View File
@@ -69,5 +69,7 @@
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
"auditTitle": "Nhật ký audit",
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
"auditEmpty": "Chưa ghi nhận gì."
"auditEmpty": "Chưa ghi nhận gì.",
"activeCount": "{{n}} đang hoạt động",
"cost30d": "Chi phí · 30 ngày"
}
+7 -2
View File
@@ -351,9 +351,14 @@
}
@layer base {
body {
font-weight: 500;
}
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--border)) rgb(var(--surface-2));
/* Claude's: a faint thumb on no track. */
scrollbar-color: rgb(var(--fg-muted) / 0.35) transparent;
}
*::-webkit-scrollbar {
@@ -362,7 +367,7 @@
}
*::-webkit-scrollbar-track {
background: rgb(var(--surface-2));
background: transparent;
}
*::-webkit-scrollbar-thumb {
+19 -2
View File
@@ -3,12 +3,13 @@
* @description Pins where a pasted diff comment lands: the workspace's pane
* focused last, else any of its panes, never another workspace's (lane 1 is
* not lane 12), nowhere once the pane unmounted, and never into a pane that
* is not on screen (a hidden console behind a full-width Changes panel).
* is not on screen (a hidden console behind a full-width Changes panel); and
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { notePaneFocus, pasteToLane, registerPane } from "../panePaste";
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
describe("panePaste", () => {
it("pastes into the workspace's last-focused pane, and only its own", () => {
@@ -48,4 +49,20 @@ describe("panePaste", () => {
expect(got).toEqual(["y"]);
off();
});
it("interruptRun presses Esc in a visible pane only", () => {
const keys: string[] = [];
let shown = false;
const off = registerPane(
"agenthub-lane-6",
() => {},
() => shown,
(d) => keys.push(d)
);
expect(interruptRun("agenthub-lane-6")).toBe(false);
shown = true;
expect(interruptRun("agenthub-lane-6")).toBe(true);
expect(keys).toEqual(["\x1b"]);
off();
});
});
+77 -24
View File
@@ -555,6 +555,8 @@ export type AuditEntry = {
/** Who is using the dashboard: local mode has no login at all. */
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
@@ -642,22 +644,30 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
*/
/**
* What a workspace git call works on: the workspace (its id) or, with
* `session`, that session's own worktree (`claude --worktree`) — the server
* resolves the tree from the session row (`?session=`), never from a path.
* `session`, that session's own worktree (`claude --worktree`), or, with
* `source`, one of the workspace's extra folders — the server resolves the
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
* never from a path.
*/
export type GitTarget = number | { lane: number; session?: string };
export type GitTarget = number | { lane: number; session?: string; source?: number };
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const params = new URLSearchParams(q);
const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session);
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
/** The target for `laneId`, in `session`'s worktree when one is given. */
export function gitTarget(laneId: number, session?: string | null): GitTarget {
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
export function gitTarget(
laneId: number,
session?: string | null,
source?: number | null
): GitTarget {
if (source) return { lane: laneId, source };
return session ? { lane: laneId, session } : laneId;
}
@@ -675,13 +685,15 @@ export const api = {
/** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
},
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
// ───────────────────────── The account's own preferences (routes/me.js) ─────────────────────────
me: {
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
prefs: () => request<MePrefs>("/me/prefs"),
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
/** GET /api/me/prefs — this account's preferences and first-run flag. */
prefs: () => request<UserPrefs>("/me/prefs"),
/** PUT /api/me/prefs — merge; `null` deletes a key. */
savePrefs: (prefs: Record<string, string | null>) =>
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
@@ -907,6 +919,9 @@ export const api = {
* @returns {@link SessionStats} for that one session.
*/
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
* and the skills / MCP servers it used (a Chat pane's side panel). */
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
* workspace, deletes nothing). */
patch: (id: string, body: { name?: string; archived?: boolean }) =>
@@ -2433,10 +2448,12 @@ export const api = {
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`. */
files: (cwd: string, q?: string) => {
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
* adds the folders too, as `dir/`. */
files: (cwd: string, q?: string, dirs = false) => {
const qs = new URLSearchParams({ cwd });
if (q) qs.set("q", q);
if (dirs) qs.set("dirs", "1");
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
},
/** POST /api/run/drop-image - save an image dropped on the terminal on the
@@ -2448,18 +2465,37 @@ export const api = {
headers: { "Content-Type": file.type },
}),
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
* name so the agent sees what it is. */
dropFile: (file: File) =>
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
* run id) saves it where THAT workspace's panes can read it. */
dropFile: (file: File, lane?: number | string | null) => {
const laneId =
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
const qs = new URLSearchParams({ name: file.name });
if (laneId != null) qs.set("laneId", String(laneId));
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
});
},
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
/** GET /api/run/:id - one run's current handle. */
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
/** GET /api/run/:id/permission-mode - the mode a live claude pane shows now
* (null while a menu hides it). */
permissionMode: (id: string) =>
request<{ mode: PermissionMode | "dontAsk" | null }>(
`/run/${encodeURIComponent(id)}/permission-mode`
),
/** POST /api/run/:id/permission-mode - step the pane's mode (Shift+Tab, each
* step read back) to `mode`; 409 when it cannot be reached. */
setPermissionMode: (id: string, mode: PermissionMode) =>
request<{ mode: PermissionMode }>(`/run/${encodeURIComponent(id)}/permission-mode`, {
method: "POST",
body: JSON.stringify({ mode }),
}),
/** DELETE /api/run/:id - kill the tmux session. */
kill: (id: string) =>
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
@@ -3600,6 +3636,10 @@ export interface AiProxy {
label: string;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** "shared": the host's (an admin's, in server mode), usable by everyone;
* "personal": the signed-in user's own, named `u<id>_<slug>` — server mode
* only, and nobody else lists it. */
scope?: "shared" | "personal";
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
baseUrl: string;
/** Whether a key is stored. The key itself never leaves the server. */
@@ -3651,6 +3691,9 @@ export interface AiProxyInput {
models?: Partial<AiProxyModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string;
/** Server mode, on create: an admin's profile is shared unless "personal";
* a member's is always personal. Ignored in local mode. */
scope?: "shared" | "personal";
}
/** One markdown-based config artifact (skill/agent/command/output-style),
@@ -4277,6 +4320,22 @@ export interface CodexMarketplaceActionArgs {
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
/** `GET /api/sessions/:id/outline`. `status` is the agent's own word
* (`pending` / `in_progress` / `completed`). */
export interface SessionOutline {
progress: { id: string; subject: string; status: string }[];
/** Files it wrote or edited, newest first. */
outputs: string[];
/** Files it only read, newest first. */
files: string[];
/** Files attached to its prompts (the composer's drop dir), newest first. */
uploads: string[];
/** Claude Code memory files it read or wrote, newest first. */
memory: string[];
skills: string[];
mcp: string[];
}
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
* crashed, claude exited and closed the pane). Computed fresh from tmux
@@ -4312,12 +4371,6 @@ export interface RunStartArgs {
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
* name (`agenthub-lane-<laneId>`). */
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
export interface MePrefs {
prefs: Record<string, string>;
onboardedAt: string | null;
}
export interface RunHandle {
id: string;
laneId: number | null;
@@ -4528,7 +4581,7 @@ export interface RemoteSourceInput {
* write, which it never touches. */
export interface AutostartStatus {
available: boolean;
kind: "systemd" | "launchd";
kind: "systemd" | "systemd-system" | "launchd";
enabled: boolean;
foreign: boolean;
}
+11
View File
@@ -0,0 +1,11 @@
/**
* @file brand.ts
* @description SmartGift logo URLs shared by the sidebar and the login page.
* Remote URLs: the logo is owned by the brand, not vendored here — a machine
* with no network shows the alt text instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
export const BRAND_MARK = "https://smartgift.vn/favicon.ico";
+64 -15
View File
@@ -1,11 +1,15 @@
/**
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
* any browser and differs between users of one dashboard. Read once per page
* load and shared through `useChatUi`; `saveChatUi` updates every reader at
* once and puts the old value back if the save fails.
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
* chat/terminal button), kept in this browser's localStorage
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
* setting is only the DEFAULT: kept by the server per account
* (`GET/PUT /api/me/prefs`, key `agenthub.chatUi` = "1"; local mode is one
* account), it is written onto a session the first time that session is
* shown, so changing it later leaves every session already seen alone. Read
* once per page load and shared through `useChatUi`; `saveChatUi` updates
* every reader at once and puts the old value back if the save fails.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,21 +18,44 @@ import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi";
export const VIEWS_KEY = "agenthub.chatUi.sessions";
const MAX_VIEWS = 200;
let chatUi = false;
let loaded = false;
let loading: Promise<void> | null = null;
const listeners = new Set<() => void>();
const subscribe = (fn: () => void) => {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
};
const emit = (next: boolean) => {
chatUi = next;
listeners.forEach((fn) => fn());
};
function readViews(): Record<string, boolean> {
try {
const v: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}");
return v && typeof v === "object" ? (v as Record<string, boolean>) : {};
} catch {
return {};
}
}
let views = readViews();
function load() {
// A failed read (logged out, offline) must never take a page down with it.
loading ??= Promise.resolve()
.then(() => api.me.prefs())
.then(
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"),
(r) => {
loaded = true;
emit(r.prefs[CHAT_UI_KEY] === "1");
},
() => {
loading = null; // the next reader asks again
}
@@ -37,13 +64,32 @@ function load() {
export function useChatUi(): boolean {
useEffect(load, []);
return useSyncExternalStore(
(fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
() => chatUi
);
return useSyncExternalStore(subscribe, () => chatUi);
}
/** Shows `sessionId` as a chat (`true`) or its terminal, for this browser. */
export function setSessionChat(sessionId: string, on: boolean) {
const { [sessionId]: _old, ...rest } = views;
// Newest last, so the cap drops the sessions touched longest ago.
views = Object.fromEntries(Object.entries({ ...rest, [sessionId]: on }).slice(-MAX_VIEWS));
try {
localStorage.setItem(VIEWS_KEY, JSON.stringify(views));
} catch {
// Storage full or blocked: the choice still holds for this page load.
}
listeners.forEach((fn) => fn());
}
/** The session's own view; a session seen for the first time takes the
* account's default (once it is read) and keeps it. */
export function useSessionChat(sessionId: string | null | undefined): boolean {
const fallback = useChatUi();
const own = useSyncExternalStore(subscribe, () => (sessionId ? views[sessionId] : undefined));
const ready = useSyncExternalStore(subscribe, () => loaded);
useEffect(() => {
if (sessionId && own === undefined && ready) setSessionChat(sessionId, chatUi);
}, [sessionId, own, ready]);
return own ?? fallback;
}
export async function saveChatUi(on: boolean): Promise<void> {
@@ -61,5 +107,8 @@ export async function saveChatUi(on: boolean): Promise<void> {
/** Tests: forget what was read. */
export function __resetChatUi() {
chatUi = false;
loaded = false;
loading = null;
views = {};
localStorage.removeItem(VIEWS_KEY);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* @file openInChanges.ts — asks the Workspace page to open a file in its
* Changes panel: a chat's "Edited N files" card names absolute paths deep in
* a pane, the panel lives on the page, so the request travels as a window
* event (like the sidebar's `LIVE_RUNS_EVENT`). The page opens the panel and
* that file there, if the panel shows the same workspace; else nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const OPEN_IN_CHANGES_EVENT = "agenthub:open-in-changes";
/** A file to open: its workspace, and its path relative to the panel's folder. */
export type OpenInChanges = { laneId: number; path: string };
/** Opens `path` (absolute) when it lies under `cwd`, the pane's folder — the
* folder the Changes panel reads for it. False when it does not. */
export function openInChanges(laneId: number, cwd: string, path: string): boolean {
const base = cwd.replace(/\/+$/, "");
if (!base || !path.startsWith(`${base}/`)) return false;
const detail: OpenInChanges = { laneId, path: path.slice(base.length + 1) };
window.dispatchEvent(new CustomEvent(OPEN_IN_CHANGES_EVENT, { detail }));
return true;
}
+17 -5
View File
@@ -9,25 +9,28 @@
* hidden behind a full-width Changes panel or a replay would swallow it
* unseen); the caller says where it went, or that it went nowhere. The one
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
* what the user typed there, so it pastes AND presses Enter.
* what the user typed there, so it pastes AND presses Enter — and its Stop
* button presses Esc through `interruptRun`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
interface Pane {
paste: (text: string, submit?: boolean) => void;
visible: () => boolean;
key?: (data: string) => void;
}
const panes = new Map<string, Pane>();
let lastFocused: string | null = null;
/** Registers a mounted terminal (`visible`: is it on screen now); returns its
* unregister. */
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
* raw input, as a key press); returns its unregister. */
export function registerPane(
runId: string,
paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true
visible: () => boolean = () => true,
key?: (data: string) => void
): () => void {
const entry = { paste, visible };
const entry = { paste, visible, key };
panes.set(runId, entry);
return () => {
if (panes.get(runId) === entry) panes.delete(runId);
@@ -63,6 +66,15 @@ export function submitToRun(runId: string, text: string): boolean {
return true;
}
/** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn
* they are running. False when the pane is not mounted or not attached. */
export function interruptRun(runId: string): boolean {
const p = panes.get(runId);
if (!p?.visible() || !p.key) return false;
p.key("\x1b");
return true;
}
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
* last when it is — and returns its run id; null when none is on screen. */
export function pasteToLane(laneId: number, text: string): string | null {
+8 -1
View File
@@ -2,7 +2,7 @@
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
* expanded to their sessions (by lane id), the tool the list is filtered to,
* and — Settings → Worktrees —
* which owner groups an admin/analyst folded, and — Settings → Worktrees —
* whether a workspace home's Add folder suggests the repo's untracked
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
@@ -20,6 +20,8 @@ export interface ProjectTreePrefs {
expanded: Record<string, boolean>;
/** Show only this tool's workspaces; null = every tool. */
tool: string | null;
/** Server mode, admin/analyst: owner key (user id, or "none") → its group is folded. */
collapsedOwners: Record<string, boolean>;
showUntracked: boolean;
hideSources: string[];
}
@@ -27,6 +29,7 @@ export interface ProjectTreePrefs {
const DEFAULTS: ProjectTreePrefs = {
expanded: {},
tool: null,
collapsedOwners: {},
showUntracked: true,
hideSources: [],
};
@@ -50,6 +53,10 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
value = {
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null,
collapsedOwners:
parsed.collapsedOwners && typeof parsed.collapsedOwners === "object"
? parsed.collapsedOwners
: {},
showUntracked: parsed.showUntracked !== false,
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
};
+101
View File
@@ -0,0 +1,101 @@
/**
* @file promptAttach.ts
* @description What a prompt box can attach besides a fresh upload: a project
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
* last day (`recentUploads`, kept in this browser). An attachment is a path
* sent with the prompt, so nothing here touches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { DragEvent } from "react";
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
export interface Attachment {
path: string;
name: string;
url?: string;
}
export const ATTACH_MIME = "application/x-agenthub-attach";
/** A project path (relative to the box's folder) as Claude Code's `@` mention. */
export function projectAttachment(rel: string): Attachment {
const name = rel.replace(/\/$/, "").split("/").pop() || rel;
return { path: `@${rel}`, name: rel.endsWith("/") ? `${name}/` : name };
}
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
export function dragProjectPath(e: DragEvent, rel: string) {
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
e.dataTransfer.setData("text/plain", rel);
e.dataTransfer.effectAllowed = "copy";
}
/** The attachment a drop carries, if it is one of ours. */
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
try {
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
const a = v as Attachment | null;
return a && typeof a.path === "string" && typeof a.name === "string"
? { path: a.path, name: a.name }
: null;
} catch {
return null;
}
}
/** `prev` plus whichever of `items` it does not hold yet. */
export function addAttachments<T extends Attachment>(prev: T[], items: T[]): T[] {
const fresh = items.filter(
(a, i) =>
!prev.some((p) => p.path === a.path) && items.findIndex((b) => b.path === a.path) === i
);
return fresh.length ? [...prev, ...fresh] : prev;
}
const RECENT_KEY = "agenthub.prompt.recentUploads";
const RECENT_MAX = 20;
// The server prunes an upload a day after it was written (`saveDrop`).
// ponytail: a client-side guess of that TTL, a few minutes short; a listing
// route instead if uploads must outlive it or follow the user to another browser.
const RECENT_TTL_MS = 23.9 * 60 * 60_000;
interface Recent extends Attachment {
at: number;
}
function readRecent(): Recent[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
const cutoff = Date.now() - RECENT_TTL_MS;
return (Array.isArray(v) ? (v as Recent[]) : []).filter(
(r) => typeof r?.path === "string" && typeof r.name === "string" && r.at > cutoff
);
} catch {
return [];
}
}
export function recentUploads(): Attachment[] {
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
}
/** Remember uploads just saved, newest first; an image keeps a thumbnail
* through `GET /api/run/drop/:name` (its local blob URL dies with the page). */
export function rememberUploads(items: { path: string; name: string; image: boolean }[]) {
const at = Date.now();
const added: Recent[] = items.map(({ path, name, image }) => ({
path,
name,
at,
...(image ? { url: `/api/run/drop/${encodeURIComponent(path.split("/").pop() || "")}` } : {}),
}));
const kept = readRecent().filter((r) => !added.some((a) => a.path === r.path));
try {
localStorage.setItem(RECENT_KEY, JSON.stringify([...added, ...kept].slice(0, RECENT_MAX)));
} catch {
/* storage full or blocked: the list is a convenience */
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* @file sessionDefaults.ts
* @description The account's own starting choices for new work, picked in the
* first-run setup (docs/plans/onboarding) and synced per account like every
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
* the permission mode a new Claude session starts in. Bypass is NEVER one of
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
* is refused on read and on write.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { PermissionMode } from "./api";
const TOOL_KEY = "agenthub.defaultTool";
const MODE_KEY = "agenthub.permissionMode";
/** The permission modes a default may be. */
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
export type DefaultMode = (typeof DEFAULT_MODES)[number];
function read(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function write(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* storage blocked */
}
}
/** The account's own default AI tool id, or null (the install's default applies). */
export const readDefaultTool = (): string | null => read(TOOL_KEY);
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
export function readDefaultMode(): PermissionMode {
const v = read(MODE_KEY);
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
}
export function writeDefaultMode(mode: DefaultMode): void {
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
}
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* @file terminalFont — the lane terminal's font, a viewer preference kept in
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
* terminal opens. `jetbrains` (the default) and `maple` (rounded) are bundled
* webfonts that cover every Vietnamese letter; `system` is the browser's own
* terminal opens. `jetbrains` (the default) and the rounded `maple` are
* bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
* and those glyphs then fall back to a face of another width).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
+9
View File
@@ -1344,6 +1344,7 @@ export interface UpdateStatusPayload {
situation?:
| "plugin_managed"
| "release_available"
| "diverged_from_release"
| "tracking_canonical"
| "fork_or_diverged_tracking"
| "feature_branch"
@@ -2661,6 +2662,8 @@ export interface Lane {
cwd: string;
/** Server mode: the account that owns this workspace (null = nobody yet). */
owner_id?: number | null;
/** The owner's username (`users.username`); null with no owner. */
owner_name?: string | null;
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
@@ -3205,6 +3208,9 @@ export interface TranscriptContent {
output?: string;
/** Present for "tool_result" blocks: whether the tool call errored. */
is_error?: boolean;
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number };
}
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
@@ -3239,6 +3245,9 @@ export interface TranscriptMessage {
* Example: `"claude-opus-4-8"`. */
model?: string;
/** Token accounting reported alongside an assistant message; absent otherwise. */
/** Assistant lines: why the model stopped — "end_turn" closes the turn,
* "tool_use" means more work follows. Absent from an older server. */
stop_reason?: string;
usage?: {
/** Fresh (non-cached) input tokens for this message. */
input_tokens: number;
+185
View File
@@ -0,0 +1,185 @@
/**
* @file userPrefs.ts
* @description An account's UI preferences, kept per ACCOUNT on the server
* (`/api/me/prefs`, docs/plans/onboarding) while every component goes on
* reading and writing localStorage as it always did. On sign-in `hydrate()`
* copies the account's stored values into localStorage (the account wins),
* sends up the ones this browser has and the account does not yet, and makes
* the theme, language and pet re-read them. From then on a write to one of
* `PREF_KEYS` through `localStorage.setItem`/`removeItem` is sent to the
* server, batched. `useOnboarding()` tells the setup wizard whether this
* account has been through it. Never throws: a server that cannot answer
* leaves the browser's own values in charge. The same row also holds
* `agenthub.chatUi`, which `lib/chatUi.ts` reads and writes on the server
* directly (no localStorage copy).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
import { api } from "./api";
import i18n from "../i18n";
import { applyStoredTheme } from "../hooks/useTheme";
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
export const PREF_KEYS = [
"theme",
"agenthub.theme.dark",
"agenthub.theme.light",
"i18nextLng",
"agenthub.terminal.font",
"agenthub.pet",
"agenthub.pet.size",
"agent-dashboard-sagi-enabled",
"agent-dashboard-sagi-muted",
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.tour",
] as const;
const SYNCED = new Set<string>(PREF_KEYS);
const PUSH_DELAY_MS = 400;
// `forced`: Settings asked for the setup again, whatever the account has.
type State = { loaded: boolean; onboardedAt: string | null; forced: boolean };
let state: State = { loaded: false, onboardedAt: null, forced: false };
const listeners = new Set<() => void>();
const setState = (next: Partial<State>) => {
state = { ...state, ...next };
listeners.forEach((l) => l());
};
let pending: Record<string, string | null> = {};
let timer: ReturnType<typeof setTimeout> | undefined;
let syncing = false;
function queue(key: string, value: string | null) {
if (!syncing) return;
pending[key] = value;
clearTimeout(timer);
timer = setTimeout(flush, PUSH_DELAY_MS);
}
/** Send what is queued now (also on page hide). */
export function flush(): Promise<void> {
clearTimeout(timer);
const batch = pending;
pending = {};
if (!Object.keys(batch).length) return Promise.resolve();
return api.me
.savePrefs(batch)
.then(() => undefined)
.catch(() => {
/* offline or signed out: the browser keeps its own copy */
});
}
let wrapped = false;
function wrapStorage() {
if (wrapped || typeof Storage === "undefined") return;
wrapped = true;
const proto = Storage.prototype;
const setItem = proto.setItem;
const removeItem = proto.removeItem;
proto.setItem = function (key: string, value: string) {
setItem.call(this, key, value);
if (this === window.localStorage && SYNCED.has(key)) queue(key, String(value));
};
proto.removeItem = function (key: string) {
removeItem.call(this, key);
if (this === window.localStorage && SYNCED.has(key)) queue(key, null);
};
window.addEventListener("pagehide", () => void flush());
}
function readLocal(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
/** Make what reads these keys read them again. */
function reapply(prefs: Record<string, string>) {
// Painted now, not only on the next useTheme render: the login page wore the OS's.
applyStoredTheme();
window.dispatchEvent(new Event("agenthub-theme"));
window.dispatchEvent(new Event("sagi:prefs"));
const lng = prefs.i18nextLng;
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
}
/**
* Load the signed-in account's preferences into this browser and start
* sending changes. Call once a login (or local mode) is known.
*/
export async function hydrate(): Promise<void> {
syncing = false;
pending = {};
let answer;
try {
answer = await api.me.prefs();
} catch {
// No answer (an older server, a test with no API): no wizard, no sync —
// the browser's own values stay in charge.
return;
}
const up: Record<string, string> = {};
for (const key of PREF_KEYS) {
const theirs = answer.prefs[key];
if (typeof theirs === "string") {
try {
window.localStorage.setItem(key, theirs);
} catch {
/* storage blocked */
}
} else {
const mine = readLocal(key);
if (mine !== null) up[key] = mine;
}
}
wrapStorage();
syncing = true;
reapply(answer.prefs);
if (Object.keys(up).length) {
pending = { ...up };
void flush();
}
setState({ loaded: true, onboardedAt: answer.onboardedAt });
}
/** The first-run setup is done or skipped, for this account. */
export async function markOnboarded(): Promise<void> {
await flush();
const at = new Date().toISOString();
setState({ onboardedAt: at, forced: false });
try {
const r = await api.me.onboarded();
setState({ onboardedAt: r.onboardedAt || at });
} catch {
/* shown again next time — better than never saving the choices */
}
}
/** Show the setup again (Settings → System). */
export function resetOnboarding(): void {
setState({ onboardedAt: null, forced: true });
}
const subscribe = (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
};
/** `{loaded, onboardedAt, forced}` for the account signed in now. */
export function useOnboarding(): State {
return useSyncExternalStore(subscribe, () => state);
}
/** Tests only. */
export function __resetUserPrefs(): void {
state = { loaded: false, onboardedAt: null, forced: false };
pending = {};
syncing = false;
clearTimeout(timer);
}
+3
View File
@@ -57,15 +57,18 @@ import "@fontsource/inter/latin-600.css";
import "@fontsource/inter/latin-700.css";
import "@fontsource/jetbrains-mono/latin-400.css";
import "@fontsource/jetbrains-mono/latin-500.css";
import "@fontsource/jetbrains-mono/latin-700.css";
// The lane terminal renders live Vietnamese output in this font (TerminalView),
// which the latin subset above doesn't cover — without it, missing glyphs fall
// back to another installed face with a different advance width and get
// visually mangled inside xterm's fixed-width cells.
import "@fontsource/jetbrains-mono/vietnamese-400.css";
import "@fontsource/jetbrains-mono/vietnamese-500.css";
import "@fontsource/jetbrains-mono/vietnamese-700.css";
// The rounded terminal font (Settings → Appearance). Its one file already holds
// the Vietnamese glyphs; a face is only downloaded once something uses it.
import "@fontsource/maple-mono/400.css";
import "@fontsource/maple-mono/500.css";
import "@fontsource/maple-mono/700.css";
import App from "./App";
import { AuthGate } from "./components/AuthGate";
+49 -3
View File
@@ -21,6 +21,11 @@
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
* as a menu; the buttons stay.
*
* In server mode a profile is SHARED (an admin's, usable by everyone) or
* PERSONAL (the signed-in user's own, badged "Personal"). A member edits only
* their own and every profile they add is personal; an admin chooses when
* adding; an analyst only reads.
*
* **Test** asks the endpoint itself, once as Claude Code would
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
* "Saved" and "works" are different claims, and the gap between them is only
@@ -43,13 +48,15 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../compone
import { confirmDialog } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import { useAuth } from "../components/AuthGate";
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs"> & {
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
apiKey: string;
behavesAs: string;
models: Partial<AiProxyModels>;
scope: "shared" | "personal";
};
const emptyForm = (): FormState => ({
@@ -59,10 +66,17 @@ const emptyForm = (): FormState => ({
model: "",
models: {},
behavesAs: "",
scope: "shared",
});
export function AiProxyConfig() {
const { t } = useTranslation("settings");
// Server mode's signed-in user; null in local mode, where the host owns
// every (shared) profile.
const { state: auth } = useAuth();
const user = auth.mode === "server" ? auth.user : null;
const isAdmin = !user || user.role === "admin";
const canWrite = (item: AiProxy) => !item.builtin && (item.scope === "personal" || isAdmin);
const [items, setItems] = useState<AiProxy[]>([]);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<AiProxy | null>(null);
@@ -131,6 +145,7 @@ export function AiProxyConfig() {
model: item.model,
models: { ...item.models },
behavesAs: item.behavesAs ?? "",
scope: item.scope ?? "shared",
});
setFormError(null);
setOpen(true);
@@ -187,6 +202,7 @@ export function AiProxyConfig() {
behavesAs: form.behavesAs.trim(),
};
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
if (user && isAdmin && !editing) body.scope = form.scope;
if (editing) await api.aiProxies.update(editing.name, body);
else await api.aiProxies.create(body);
@@ -253,13 +269,18 @@ export function AiProxyConfig() {
{items.map((item) => (
<tr
key={item.name}
onContextMenu={item.builtin ? undefined : (e) => ctx.open(e, item)}
onContextMenu={canWrite(item) ? (e) => ctx.open(e, item) : undefined}
className="border-t border-border"
>
<td className="px-3 py-2">
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
{item.label}
{item.scope === "personal" && (
<span className="rounded bg-accent/15 px-1.5 py-px text-[10px] font-normal text-accent">
{t("aiProxy.personal")}
</span>
)}
</span>
{item.builtin && (
<span className="mt-0.5 block text-[10px] text-fg-muted">
@@ -277,7 +298,7 @@ export function AiProxyConfig() {
{item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")}
</td>
<td className="px-3 py-2 text-right">
{!item.builtin && (
{canWrite(item) && (
<span className="inline-flex gap-1">
<button
type="button"
@@ -349,6 +370,31 @@ export function AiProxyConfig() {
value={form.label}
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
/>
{user && !editing && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="proxy-scope">
{t("aiProxy.fieldScope")}
</label>
{isAdmin ? (
<select
id="proxy-scope"
value={form.scope}
onChange={(e) =>
setForm((f) => ({ ...f, scope: e.target.value as FormState["scope"] }))
}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
<option value="shared">{t("aiProxy.scopeShared")}</option>
<option value="personal">{t("aiProxy.scopePersonal")}</option>
</select>
) : (
<p id="proxy-scope" className="text-xs text-fg-primary">
{t("aiProxy.scopePersonal")}
</p>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("aiProxy.fieldScopeHint")}</p>
</div>
)}
<Field
id="proxy-base-url"
label={t("aiProxy.fieldBaseUrl")}
+2 -1
View File
@@ -140,7 +140,8 @@ const MOVED: Record<string, string> = {
};
/** Sections that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
const ADMIN_SECTIONS = new Set(["data", "ai-proxy", "pricing"]);
// AI Proxy is not here: every user keeps personal profiles (S3).
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
+242 -106
View File
@@ -7,13 +7,30 @@
* an admin — the account changes: add a user, change a role, disable/enable,
* reset a password, give a workspace. Every change asks for the admin's OWN
* password again (the server refuses without it); an analyst only reads.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
import {
AlertCircle,
DollarSign,
Eye,
FolderInput,
FolderOpen,
KeyRound,
Lock,
LockOpen,
MessagesSquare,
RefreshCw,
ScrollText,
Shield,
UserPlus,
Users as UsersIcon,
} from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import type { Lane } from "../lib/types";
import { fmt, fmtCost, formatDateTime } from "../lib/format";
@@ -22,7 +39,8 @@ import { isSubmitEnter } from "../lib/keys";
import { useSpin } from "../hooks/useSpin";
import { useAuth } from "../components/AuthGate";
import { ConfirmModal } from "../components/ConfirmModal";
import { SettingsSection } from "../components/settings/SettingsRow";
import { StatCard } from "../components/StatCard";
import { TableRowSkeleton } from "../components/Skeleton";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
@@ -67,12 +85,22 @@ export function Users() {
// Local mode, or a developer: there is no Users page for them.
if (!me || me.role === "developer") return <Navigate to="/" replace />;
const sum = (pick: (u: ServerUserUsage) => number) =>
(users ?? []).reduce((n, u) => n + pick(u), 0);
const active = (users ?? []).filter((u) => !u.disabled).length;
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<div className="animate-fade-in space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<UsersIcon className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
@@ -97,115 +125,206 @@ export function Users() {
</div>
</div>
<SettingsSection title={t("title")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
{users && (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="users-table">
<thead className="text-left text-[11px] uppercase text-fg-muted">
<tr>
<th className="py-2 pr-3 font-medium">{t("name")}</th>
<th className="py-2 pr-3 font-medium">{t("role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => {
const self = u.id === me.id;
return (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
</td>
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
{t(`roles.${u.role}`)}
</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
{isAdmin && (
<td className="py-1 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingsSection>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label={t("title")}
value={users?.length ?? 0}
icon={UsersIcon}
trend={users ? t("activeCount", { n: active }) : undefined}
loading={!users && !error}
/>
<StatCard
label={t("workspaces")}
value={sum((u) => u.workspaces)}
icon={FolderOpen}
loading={!users && !error}
/>
<StatCard
label={t("sessions")}
value={sum((u) => u.sessions)}
icon={MessagesSquare}
loading={!users && !error}
/>
<StatCard
label={t("cost30d")}
value={fmtCost(sum((u) => u.last30d.cost))}
icon={DollarSign}
trend={users ? `${t("last7d")}: ${fmtCost(sum((u) => u.last7d.cost))}` : undefined}
raw={t("tokens", { n: fmt(sum((u) => u.last30d.tokens)) })}
loading={!users && !error}
/>
</div>
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
{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>
)}
{!isAdmin && (
<p className="flex items-center gap-1.5 text-xs text-fg-muted">
<Eye className="w-3.5 h-3.5" /> {t("readOnly")}
</p>
)}
<div className="card overflow-x-auto">
<table className="w-full min-w-[760px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
<th className={th}>{t("role")}</th>
<th className={`${th} text-right`}>{t("workspaces")}</th>
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{!users &&
!error &&
Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={isAdmin ? 7 : 6}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
/>
))}
{users?.map((u) => {
const self = u.id === me.id;
return (
<tr
key={u.id}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-5 py-3">
<div className="flex items-center gap-3 min-w-0">
<span
aria-hidden
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
>
{u.username.slice(0, 2)}
</span>
<span className="truncate text-sm font-medium text-fg-primary">
{u.username}
</span>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
</div>
</td>
<td className="px-5 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.workspaces}
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions}
</td>
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
<section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<ScrollText className="w-4 h-4 text-fg-muted" />
<div>
<h2 className="text-sm font-semibold text-fg-primary">{t("auditTitle")}</h2>
<p className="text-xs text-fg-muted">{t("auditDescription")}</p>
</div>
</div>
{audit?.length === 0 && (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("auditEmpty")}</p>
)}
{audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
<ul
className="divide-y divide-border text-xs max-h-[28rem] overflow-y-auto"
data-testid="audit-list"
>
{audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5">
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
<span className="w-40 flex-shrink-0 text-fg-secondary">
{a.reader} → {a.owner}
<li
key={a.id}
className="flex items-center gap-3 px-5 py-2 hover:bg-surface-4/40 transition-colors"
>
<span className="w-36 flex-shrink-0 tabular-nums text-fg-muted">
{formatDateTime(a.at)}
</span>
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
<span className="font-medium text-fg-primary">{a.reader}</span>
<span className="mx-1.5 text-fg-muted">→</span>
{a.owner}
</span>
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
{a.method}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted" title={a.path}>
{a.path}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li>
))}
</ul>
)}
</SettingsSection>
</section>
{dialog && (
<AccountDialog
@@ -223,6 +342,23 @@ export function Users() {
);
}
const ROLE_BADGE: Record<Role, string> = {
admin: "bg-accent/15 text-accent border-accent/20",
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
developer: "bg-surface-4 text-fg-secondary border-border",
};
/** A period's cost, with its tokens underneath. */
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
const { t } = useTranslation("users");
return (
<td className="px-5 py-3 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
</td>
);
}
function RowAction({
label,
disabled,
+280 -130
View File
@@ -15,8 +15,9 @@
* a tab click replaces the history entry (Back leaves the session view, not
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
* splits the focused one right with a new terminal, Alt+W hides it. The
* Changes panel resizes by its button's presets or by dragging (or arrow keys
* on) its splitter; an unsaved edit in it is asked about before the panel
* Changes panel has Full screen (Restore at full screen) and Collapse
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
* closes, switches tab, or moves to another workspace or session (it stays on
* the old one until saved or discarded). Errors are toasts.
*
@@ -55,18 +56,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Plus,
Columns2,
Layers,
PanelLeftClose,
PanelRightClose,
Maximize2,
Minimize2,
ChartColumn,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
import type {
CwdSuggestion,
LaneSource,
LaneSourcesView,
RunHandle,
RunListResponse,
} from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useWorkLanes } from "../hooks/useLanes";
@@ -109,6 +117,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
@@ -118,39 +127,37 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the last width the user dragged the panel to — what
* it reopens at after the step control closes it. */
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
* 30/70 → 50/50 → full → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "full"] as const;
/** The Changes panel's two preset widths. Its header has two controls, like a
* window's: Full screen (Restore once at full screen, back to the last
* dragged width) and Collapse (to the rail). */
const CHANGES_SIZES = ["compact", "full"] as const;
type ChangesPreset = (typeof CHANGES_SIZES)[number];
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
type ChangesSize = ChangesPreset | number;
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
compact: "w-[30%]",
half: "w-1/2",
compact: "w-[25%]",
// The console beside it is HIDDEN, not unmounted: a live run's terminal
// and a half-typed prompt must survive a round trip through full width.
full: "w-full",
};
/** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, the full width, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
compact: Columns2,
half: Maximize2,
full: PanelRightClose,
};
/** The panel's share of the row, for the splitter. */
const changesRatio = (s: ChangesSize) =>
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
/** The preset a size counts as for the button: under ~45% is narrow. */
const changesStep = (s: ChangesSize): ChangesPreset =>
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
const changesRatio = (s: ChangesSize) => (typeof s === "number" ? s : s === "compact" ? 0.25 : 1);
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
/** The width the user last dragged the panel to, else `compact`. */
function lastDraggedChanges(): ChangesSize {
try {
const n = Number(window.localStorage.getItem(CHANGES_DRAGGED_KEY) || NaN);
return Number.isFinite(n) ? clampChanges(n) : "compact";
} catch {
return "compact";
}
}
/** The panel's own minimum (`min-w-[18rem]`), in px. */
const CHANGES_MIN_PX = 288;
@@ -502,6 +509,7 @@ function ConsoleArea({
onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot}
solo={shown.length === 1}
attachRequest={
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
}
@@ -646,17 +654,14 @@ export function Workspace() {
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
if (typeof changesSize === "number")
window.localStorage.setItem(CHANGES_DRAGGED_KEY, String(changesSize));
} catch {
/* same as above: a forgotten width, not an error */
}
}, [changesSize]);
const changesFull = changesSize === "full";
const changesPreset = changesStep(changesSize);
const nextChangesSize =
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel = changesFull
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
const changesSizeLabel = changesFull ? tLanes("changes.restore") : tLanes("changes.size.full");
const changesRowRef = useRef<HTMLDivElement>(null);
// The row's width: the panel never gets narrower than its 18rem minimum,
// so the splitter is drawn — and dragged — no further than that edge.
@@ -713,16 +718,69 @@ export function Workspace() {
? sessionParam
: undefined;
// An unsaved whole-file edit in the Changes panel. While it is open the
// panel stays on the tree it was opened in — moving it (another workspace,
// session or worktree) would remount it and drop the edit — until the user
// says to discard it, or saves.
const [changesDirty, setChangesDirty] = useState(false);
// An unsaved whole-file edit in a Changes panel section, by section key. While
// one is open the panel stays on the tree it was opened in — moving it
// (another workspace, session or worktree) would remount it and drop the
// edit — until the user says to discard it, or saves.
const [dirtyBy, setDirtyBy] = useState<Record<string, boolean>>({});
const changesDirty = Object.values(dirtyBy).some(Boolean);
const dirtyFor = useCallback(
(key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })),
[]
);
const wantPanel =
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : "");
const wantPanelKey = keyOf(wantPanel);
const [panelAt, setPanelAt] = useState(wantPanel);
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
const panelKey = keyOf(panelAt);
// The workspace's extra folders: each is its own repo, stacked under the
// workspace folder's in the Changes panel (`?source=`). Read for the
// workspace the panel shows, and again from the home's Folder list whenever
// it adds or removes one.
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
null
);
const takeGitFolders = useCallback(
(laneId: number, view: LaneSourcesView) =>
setGitFolders({
laneId,
items: view.sources.filter((s) => s.kind === "folder" && !s.missing),
}),
[]
);
const panelLaneId = panelAt?.laneId ?? null;
useEffect(() => {
if (panelLaneId === null) return;
api.lanes.sources(panelLaneId).then(
(v) => takeGitFolders(panelLaneId, v),
() => setGitFolders({ laneId: panelLaneId, items: [] })
);
}, [panelLaneId, takeGitFolders]);
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
// Sections folded shut ("" = the workspace folder, else the folder's id).
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
// Right-click on a section: close extra folders — which REMOVES them from the
// workspace (the folder stays on disk). The workspace folder never closes.
const sourceCtx = useContextMenu<string>();
// Bumped after a close here, so the home's Folder list re-reads.
const [sourcesVersion, setSourcesVersion] = useState(0);
const closeSources = async (laneId: number, keys: string[]) => {
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
try {
let view: LaneSourcesView | null = null;
for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
if (view) takeGitFolders(laneId, view);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
api.lanes.sources(laneId).then(
(v) => takeGitFolders(laneId, v),
() => undefined
);
}
setSourcesVersion((n) => n + 1);
};
const asking = useRef(false);
useEffect(() => {
if (wantPanelKey === panelKey) return;
@@ -735,11 +793,39 @@ export function Workspace() {
void confirmDiscard().then((ok) => {
asking.current = false;
if (!ok) return;
setChangesDirty(false);
setDirtyBy({});
setPanelAt(wantPanel);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
}, [wantPanelKey, panelKey, changesDirty]);
// A chat's "Edited N files" card asks for a file: open the panel on it, if
// the panel shows that workspace.
const [changesRequest, setChangesRequest] = useState<{ path: string; nonce: number } | null>(
null
);
const panelLaneRef = useRef<number | null>(null);
panelLaneRef.current = panelAt?.laneId ?? null;
useEffect(() => {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
if (laneId !== panelLaneRef.current) return;
// Its path is the workspace folder's: unfold that section.
setFoldedSources((prev) => {
const next = new Set(prev);
next.delete("");
return next;
});
setChangesOpen(true);
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
};
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
}, []);
// Asked once: a panel reopened or moved to another tree must not replay it.
useEffect(() => {
if (!changesOpen) setChangesRequest(null);
}, [changesOpen]);
useEffect(() => setChangesRequest(null), [panelKey]);
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
const afterDrop = (fn: () => void) => {
if (!changesDirty) return fn();
@@ -958,41 +1044,9 @@ export function Workspace() {
: "space-y-5"
}
>
{/* Workspaces are picked in the sidebar's project tree; this page shows
the selected one. With none at all, one banner offers to create it. */}
{/* Workspaces are picked — and added, with the + — in the sidebar's
project tree; this page shows the selected one. */}
<div className="flex min-h-0 flex-1 flex-col gap-4">
{lanesLoaded && lanes.length === 0 && (
/* With no lanes the strip was a dashed tile beside a stray sentence,
which reads as a half-rendered row rather than a state. One banner
instead — and a compact one on purpose: the run setup form below
is what the user actually needs the height for. */
<button
type="button"
data-testid="lane-empty"
onClick={() => setAddLaneOpen(true)}
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
<Layers className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<span className="block text-sm font-medium text-fg-primary">
{tLanes("empty.title")}
</span>
<p className="mt-0.5 text-xs text-fg-muted">
{tLanes("empty.body")}{" "}
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
<Plus className="h-4 w-4" />
{tLanes("add")}
</span>
</button>
)}
{/* The selected lane's pipeline — the thing you actually come to this
page to read, beside the list rather than stacked under it.
The section itself is ALWAYS rendered, even with no lane selected:
@@ -1011,6 +1065,8 @@ export function Workspace() {
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
sourcesVersion={sourcesVersion}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
@@ -1232,7 +1288,7 @@ export function Workspace() {
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
>
{/* Header: the title, Analyst, then the resize control — the
{/* Header: the title, Analyst, then Full screen and Collapse — the
same height as the console's tab bar, so the two read as
one header strip. Files and Changes are ONE panel now; its
own mode switcher moves between them. */}
@@ -1258,62 +1314,153 @@ export function Workspace() {
>
<ChartColumn className="h-4 w-4" />
</button>
{/* A window's two controls: Full screen (Restore at full
screen, to the last dragged width) and Collapse. */}
<button
type="button"
data-testid="lane-changes-resize"
onClick={() => {
// Past the widest step the next state is "gone", which
// is what the rail button reopens at `compact` — and
// what drops an unsaved edit, so that asks first.
if (changesFull) {
afterDrop(() => {
setChangesOpen(false);
setChangesSize("compact");
});
} else {
setChangesSize(nextChangesSize);
}
}}
// The control names the same region the rail button does,
// so a screen reader reads one panel that is open or
// closed rather than two unrelated buttons.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={changesNextLabel}
title={changesNextLabel}
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
aria-label={changesSizeLabel}
title={changesSizeLabel}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
{/* Icon-only, and it draws the width the click LANDS on,
not the current one — the label the tooltip carries. */}
{(() => {
const Next = CHANGES_NEXT_ICON[changesPreset];
return <Next className="h-4 w-4" />;
})()}
{changesFull ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
<button
type="button"
data-testid="lane-changes-collapse"
// Closing drops an unsaved edit, so that asks first; it
// reopens from the rail at the last dragged width.
onClick={() =>
afterDrop(() => {
setChangesOpen(false);
setChangesSize(lastDraggedChanges());
})
}
// The same region the rail button names, so a screen
// reader reads one panel that is open or closed.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.collapse")}
title={tLanes("changes.collapse")}
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
{panelAt && (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous one
// can land in it. `panelAt` holds still while an edit is
// unsaved (see above).
key={panelKey}
laneId={panelAt.laneId}
session={panelAt.session}
full={changesFull}
wide={changesPreset !== "compact"}
onDirtyChange={setChangesDirty}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
{panelAt &&
[
{ key: "", source: undefined as number | undefined },
...folders.map((f) => ({ key: String(f.id), source: f.id })),
].map(({ key, source }, i) => {
const panel = (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous
// one can land in it. `panelAt` holds still while an
// edit is unsaved (see above).
key={`${panelKey}:${key}`}
laneId={panelAt.laneId}
session={panelAt.session}
source={source}
full={changesFull}
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={dirtyFor(key)}
openRequest={source ? null : changesRequest}
// A sibling worktree forks from the lane's SOURCE repo
// when it has one (this lane is itself a worktree of
// it); an adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
);
// No extra folders: the one panel, as it always was.
if (!folders.length) return panel;
// Else every repo is a section, stacked like VS Code's
// Source Control; the open ones share the height. A
// folded one stays mounted (CSS), so an unsaved edit in
// it survives.
const folded = foldedSources.has(key);
const name = source
? folders[i - 1]!.name
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
return (
<section
key={key}
data-testid="panel-git-source"
className={`flex flex-col border-b border-border-light ${
folded ? "shrink-0" : "min-h-0 flex-1"
}`}
>
<button
type="button"
aria-expanded={!folded}
onContextMenu={(e) => sourceCtx.open(e, key)}
onClick={() =>
setFoldedSources((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
})
}
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
{folded ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{name}</span>
</button>
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
{panel}
</div>
</section>
);
})}
{panelAt && sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId="panel-git-source-menu"
>
{sourceCtx.menu.item !== "" && (
<MenuItem
testId="panel-git-source-close"
onSelect={() => {
const key = sourceCtx.menu!.item;
sourceCtx.close();
void closeSources(panelAt.laneId, [key]);
}}
>
{tLanes("changes.closeRepo")}
</MenuItem>
)}
<MenuItem
testId="panel-git-source-close-others"
disabled={!folders.some((f) => String(f.id) !== sourceCtx.menu!.item)}
onSelect={() => {
const key = sourceCtx.menu!.item;
sourceCtx.close();
void closeSources(
panelAt.laneId,
folders.map((f) => String(f.id)).filter((k) => k !== key)
);
}}
>
{tLanes("changes.closeOtherRepos")}
</MenuItem>
</ContextMenu>
)}
</div>
</aside>
@@ -1337,13 +1484,16 @@ export function Workspace() {
{tLanes("changes.title")}
</span>
</button>
{panelAt && (
<LaneGitRailCounts
laneId={panelAt.laneId}
session={panelAt.session}
onShowAll={() => setChangesOpen(true)}
/>
)}
{panelAt &&
[undefined, ...folders.map((f) => f.id)].map((source) => (
<LaneGitRailCounts
key={source ?? ""}
laneId={panelAt.laneId}
session={panelAt.session}
source={source}
onShowAll={() => setChangesOpen(true)}
/>
))}
</div>
))}
</div>
@@ -2,7 +2,8 @@
* @file Tests for the AI Proxy settings page — listing profiles, creating one,
* and the two rules that keep a key safe: the form never shows one, and an
* edit that leaves the field blank must not send an empty key that would wipe
* the stored one.
* the stored one. In server mode: a developer or analyst edits only their
* personal profiles and adds personal ones, an admin picks shared or personal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -12,6 +13,12 @@ import userEvent from "@testing-library/user-event";
import { AiProxyConfig } from "../AiProxyConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import type { AuthState } from "../../lib/api";
let authState: AuthState = { mode: "local" };
vi.mock("../../components/AuthGate", () => ({
useAuth: () => ({ state: authState, logout: async () => {} }),
}));
vi.mock("../../lib/api", () => ({
api: {
@@ -60,6 +67,7 @@ const nineRouter = {
};
beforeEach(() => {
authState = { mode: "local" };
vi.mocked(api.aiProxies.list)
.mockReset()
.mockResolvedValue({ items: [native] });
@@ -286,3 +294,70 @@ describe("AiProxyConfig — testing an endpoint before saving it", () => {
off();
});
});
describe("AiProxyConfig in server mode", () => {
const shared = { ...nineRouter, scope: "shared" as const };
const mine = {
...nineRouter,
name: "u7_mine",
label: "Mine",
scope: "personal" as const,
};
const signIn = (role: "admin" | "developer" | "analyst") => {
authState = { mode: "server", user: { id: 7, username: "mia", role } };
};
it("lets a developer edit only their personal profile, badged Personal", async () => {
signIn("developer");
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] });
render(<AiProxyConfig />);
await screen.findByText("Mine");
expect(screen.getByText("aiProxy.personal")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull();
expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument();
});
it("adds a developer's profile as personal, with no scope choice", async () => {
signIn("developer");
const user = userEvent.setup();
render(<AiProxyConfig />);
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).queryByRole("combobox")).toBeNull();
expect(within(dialog).getByText("aiProxy.scopePersonal")).toBeInTheDocument();
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Mine");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled());
expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBeUndefined();
});
it("lets an admin choose a personal profile", async () => {
signIn("admin");
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared] });
const user = userEvent.setup();
render(<AiProxyConfig />);
expect(
await screen.findByRole("button", { name: /aiProxy\.edit 9Router/ })
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /aiProxy\.add/ }));
await user.selectOptions(screen.getByLabelText("aiProxy.fieldScope"), "personal");
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Boss");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled());
expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBe("personal");
});
it("gives an analyst Add and Edit on personal profiles only", async () => {
signIn("analyst");
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] });
render(<AiProxyConfig />);
await screen.findByText("Mine");
expect(screen.getByRole("button", { name: /aiProxy\.add/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull();
expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument();
});
});
@@ -1,7 +1,8 @@
/**
* @file Workspace.changesPanel.test.tsx
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
* @description The Changes panel has two controls: Full screen (Restore at
* full screen, back to the last dragged width) and Collapse; the rail reopens
* it at the last dragged width, else 25/75. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -82,7 +83,26 @@ vi.mock("../../lib/api", async (importOriginal) => {
}),
pipelines: r({ pipelines: [] }),
git: r({ available: false }),
// Workspace 1 has one extra folder, its own repo (`?source=`).
sources: vi.fn((id: number) =>
Promise.resolve({
sources:
id === 1
? [
{ id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false },
{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false },
]
: [],
})
),
// Closing a folder's section removes it from the workspace.
removeSource: r({
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
}),
gitTree: r({ entries: [] }),
// A file opened in the panel on a chat's request.
changes: r({ available: false }),
gitFile: r({ available: false }),
runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
@@ -104,6 +124,8 @@ vi.mock("../../lib/eventBus", () => ({
}));
import { Workspace } from "../Workspace";
import { openInChanges } from "../../lib/openInChanges";
import { api } from "../../lib/api";
class ObserverStub {
observe() {}
@@ -150,43 +172,133 @@ describe("Workspace — the Changes panel's width", () => {
}
}
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
it("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => {
await openPanel();
const card = () => screen.getByTestId("lane-changes-card");
expect(card().className).toContain("w-[30%]");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-1/2");
const size = () => screen.getByTestId("lane-changes-resize");
expect(card().className).toContain("w-[25%]");
expect(size()).toHaveAttribute("aria-label", "Full screen");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Full width HIDES the console beside it — hidden, still mounted.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
fireEvent.click(size());
expect(card().className).toContain("w-full");
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
expect(size()).toHaveAttribute("aria-label", "Restore size");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Past the widest step the panel closes — and reopens at 30/70, not back
// at full width, which is why closing resets the width.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
// Restore brings the console back beside it.
fireEvent.click(size());
expect(card().className).toContain("w-[25%]");
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
// Collapsing from full screen reopens at 25/75, not back at full width.
fireEvent.click(size());
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(card().className).toContain("w-[30%]");
expect(card().className).toContain("w-[25%]");
});
it("remembers the width across a reload, and never asks the server", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("full");
cleanup();
await openPanel();
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
expect(screen.getByTestId("lane-changes-card").className).toContain("w-full");
});
it("keeps the rail as the only way back in", async () => {
it("restores, and reopens, at the width last dragged to, not the preset", async () => {
window.localStorage.setItem("agenthub.workspace.changesSize", "full");
window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4");
await openPanel();
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
});
it("opens on a chat's request for a file of the workspace it shows, not another's", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "0");
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts");
});
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts");
});
await settle();
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
});
it("stacks every extra folder's repo under the workspace folder's, and only its own folders", async () => {
vi.mocked(api.lanes.git).mockClear();
await openPanel();
const sections = screen.getAllByTestId("panel-git-source");
// The workspace folder, then its folders — never a context file.
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
expect(api.lanes.git).toHaveBeenCalledWith(1);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
// A folded section keeps its panel mounted, only hidden.
const toggle = sections[0]!.querySelector("button")!;
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
// A chat's file is the workspace folder's: its section unfolds.
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
});
await settle();
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
});
it("closes an extra folder from its right-click menu, removing it from the workspace — never the workspace folder", async () => {
await openPanel();
const [main, shop] = screen.getAllByTestId("panel-git-source");
fireEvent.contextMenu(main!.querySelector("button")!);
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.contextMenu(shop!.querySelector("button")!);
fireEvent.click(screen.getByTestId("panel-git-source-close"));
await settle();
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
// The last folder gone: back to the one plain panel.
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("is one plain panel for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("collapses from its narrow width too, back to the rail", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument();
});
});
@@ -103,6 +103,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
lanes: {
// The Changes panel's folder picker reads the workspace's extra folders.
sources: vi.fn().mockResolvedValue({ sources: [] }),
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return { lanes: lanesToReturn, counts: countsToReturn };
@@ -434,15 +436,10 @@ describe("Workspace page — lane integration", () => {
});
describe("Workspace layout", () => {
it("opens the add-workspace modal from the empty banner when there is none", async () => {
const user = userEvent.setup();
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
lanesToReturn = [];
await renderWorkspace();
await user.click(screen.getByTestId("lane-empty"));
expect(
await screen.findByText("Create a workspace from a working directory")
).toBeInTheDocument();
expect(screen.queryByTestId("lane-empty")).toBeNull();
});
it("shows the full card only for the selected lane, in the detail panel", async () => {
@@ -5563,82 +5563,6 @@ exports[`screen snapshots > Run 1`] = `
<div
class="flex min-h-0 flex-1 flex-col gap-4"
>
<button
class="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
data-testid="lane-empty"
type="button"
>
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted"
>
<svg
class="lucide lucide-layers h-5 w-5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
/>
<path
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
/>
<path
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
/>
</svg>
</span>
<div
class="min-w-0 flex-1"
>
<span
class="block text-sm font-medium text-fg-primary"
>
No workspaces yet
</span>
<p
class="mt-0.5 text-xs text-fg-muted"
>
A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
<code
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
>
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
<span
class="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent"
>
<svg
class="lucide lucide-plus h-4 w-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add workspace
</span>
</button>
<div
class="relative flex min-h-0 flex-1"
>
@@ -5662,6 +5586,7 @@ exports[`screen snapshots > Run 1`] = `
<div
class="absolute flex flex-col"
data-focused="true"
data-menu-bounds="true"
data-testid="pane-0"
style="left: 0%; top: 0%; width: 100%; height: 100%;"
>
@@ -7249,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.5.13 (rebuild client to match)
UI build v2.6.1 (rebuild client to match)
</span>
</p>
</div>
@@ -9444,7 +9369,7 @@ exit 0
<p
class="mt-0.5 text-xs text-fg-muted"
>
Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.
Theme, language, notification settings and pet follow your account; the sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.
</p>
<div
class="mt-2"
@@ -9609,7 +9534,7 @@ exports[`screen snapshots > Settings — notifications 1`] = `
<p
class="text-xs text-fg-muted"
>
These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.
These notification settings are saved to your account. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.
</p>
<section
class="scroll-mt-24"
+2
View File
@@ -111,6 +111,8 @@ export default {
},
backgroundColor: tinted,
borderColor: tinted,
// Text a step heavier than stock: regular (and `font-normal`) is 500.
fontWeight: { normal: "500" },
fontFamily: {
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
+102 -15
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -105,9 +105,11 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database; `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
### Interactive REPL
+13 -14
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -85,8 +85,10 @@ Internal build — all rights reserved.
light: Default, Apple Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
It also picks the workspace terminals' font: JetBrains Mono (default), the
rounded Maple Mono, or the browser's own fixed-width font (which may lack some
Vietnamese letters). Per-browser only.
rounded Maple Mono, or the browser's own fixed-width
font (which may lack some Vietnamese letters). Per-browser only. Text across
the UI and the terminals is one step heavier than stock (regular 500, bold
700), and the Chat UI default sits at the top of the page.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
Pop, Alert — off by default) played with every notification shown, and each
notification type can pick its own.

Some files were not shown because too many files have changed in this diff Show More