diff --git a/CLAUDE.md b/CLAUDE.md index 092cbdec..d5a03365 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.5.13 (2026-10-08) — roles admin/analyst/developer (`usePermissions`), Chat UI per account (Settings → Appearance, `/api/me/prefs`, `PaneChat.tsx`), one shared terminal size per pane (`pty-attach.js`). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.5.13 + S3 P4–P8 (2026-10-09) — S3 review round 5: host folders admin-only, pane cwd/token bound to the pane's account, bounded sudo, capped member reads (`readLines`). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -76,8 +76,9 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one. - **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id. - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). -- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is still NOT OS isolation: every pane runs as the host account until S3, so do not call it multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. -- **A pool token reaches a pane only through tmux `-e`, and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (`agenthub accounts add`). An empty pool must leave every spawn exactly as before. +- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. +- **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable — it never deletes, never adopts a non-member account, and root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. Operator steps: `docs/SERVER-MODE.md` § Operator runbook. +- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`). An empty pool must leave every spawn exactly as before. The ONE exception is a Codex ChatGPT login (`server/lib/codex-pool.js`), which Codex reads from no env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one; `syncBack` copies the newest back. A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. - Never silently weaken safety controls around destructive actions. diff --git a/bin/agenthub.js b/bin/agenthub.js index 113e5cb1..ff677756 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -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 [--role admin|analyst|developer] | passwd | role | disable | enable | assign | audit [--limit N]" + "✖ Usage: agenthub users list | add [--role admin|analyst|developer] | passwd | role | disable | enable | provision |--all | assign | 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 --tool codex --api-key | --from \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 | remove --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} `); + if (!name) throw new Error(`usage: agenthub accounts ${sub} [--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 | remove ")); + console.error( + c.red("✖ Usage: agenthub accounts list | add | remove [--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": diff --git a/client/src/components/AuthGate.tsx b/client/src/components/AuthGate.tsx index 71da56fa..6a957037 100644 --- a/client/src/components/AuthGate.tsx +++ b/client/src/components/AuthGate.tsx @@ -8,6 +8,8 @@ * (`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`). * @author Nguyễn Ngọc Trí Vĩ */ @@ -21,6 +23,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api"; +import { hydrate } from "../lib/userPrefs"; type AuthValue = { state: AuthState; logout: () => Promise }; @@ -114,6 +117,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 setState({ mode: "server", user })} />; diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 1fc357e1..835b2947 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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) { + + {drawer && drawerOpen && (
(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 @@ -355,6 +363,7 @@ export function AddLaneModal({ role="dialog" aria-modal="true" aria-labelledby={titleId} + data-tour="add-lane" >

@@ -449,7 +458,7 @@ export function AddLaneModal({ -
+
{done ? t("addLaneSetupClose") : t("add")} diff --git a/client/src/components/lanes/SessionReplay.tsx b/client/src/components/lanes/SessionReplay.tsx index c04a9cd2..bafb6e46 100644 --- a/client/src/components/lanes/SessionReplay.tsx +++ b/client/src/components/lanes/SessionReplay.tsx @@ -173,6 +173,7 @@ export function SessionReplay({ rows={2} slash={tool === "claude"} fileCwd={session?.cwd || lane.cwd} + lane={lane.id} />
{t("replay.hint")} diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index 5f0097da..a656a953 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -85,6 +85,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 ""; @@ -173,7 +174,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("auto"); + // The account's own default (first-run setup) — never bypass. + const [mode, setMode] = useState(readDefaultMode); const [sandbox, setSandbox] = useState("workspace-write"); const [approval, setApproval] = useState("on-request"); // Per session, never remembered. @@ -529,6 +531,7 @@ function HomeBody({ rows={3} slash={claude} fileCwd={lane.cwd} + lane={lane.id} images={images} onImagesChange={setImages} onBusyChange={setUploading} diff --git a/client/src/components/onboarding/OnboardingTour.tsx b/client/src/components/onboarding/OnboardingTour.tsx new file mode 100644 index 00000000..fd564926 --- /dev/null +++ b/client/src/components/onboarding/OnboardingTour.tsx @@ -0,0 +1,224 @@ +/** + * @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 three `data-tour` marks in AddLaneModal. 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ĩ + */ + +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: { + target: () => + shown('[data-testid="lane-empty"]') || shown('[data-testid="workspace-tree-add"]'), + next: () => (modalOpen() ? "folder" : null), + }, + folder: { + target: () => $('[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(read); + const [rect, setRect] = useState(null); + // The dialog the target sits in, if any: the card goes beside the dialog. + const [box, setBox] = useState(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(); + const r = el ? el.getBoundingClientRect() : null; + 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( +
+ {rect ? ( +
+ ) : ( + step === "finish" &&
+ )} +
+

+ {t("tour.progress", { n, total: ORDER.length })} +

+

{t(`tour.${step}.title`)}

+

{t(`tour.${step}.body`)}

+
+ {step !== "finish" && ( + + )} + + {lost && !location.pathname.startsWith("/workspace") && ( + + )} + {STEPS[step].info && ( + + )} +
+
+
, + document.body + ); +} diff --git a/client/src/components/onboarding/OnboardingWizard.tsx b/client/src/components/onboarding/OnboardingWizard.tsx new file mode 100644 index 00000000..d8e34e29 --- /dev/null +++ b/client/src/components/onboarding/OnboardingWizard.tsx @@ -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ĩ + */ + +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 ? : 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(null); + useDialog(true, panelRef); + + const skip = () => void markOnboarded(); + const next = () => { + if (!last) return setIndex(index + 1); + void markOnboarded(); + startTour(); + navigate("/workspace"); + }; + + // 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 ( +
+
+
+
+ + {t("brand")} +
+
+ {STEPS.map((s, i) => ( + + ))} + + {t("progress", { n: index + 1, total: STEPS.length })} + +
+
+ +
+ {step === "agent" && } + {step === "look" && } + {step === "pet" && ( + + + + )} + {step === "notify" && } +
+ +
+ + + {index > 0 && ( + + )} + +
+
+
+ ); +} + +function StepHead({ + eyebrow, + title, + body, + children, +}: { + eyebrow?: string; + title: string; + body: string; + children: ReactNode; +}) { + return ( +
+
+ {eyebrow && ( +

+ {eyebrow} +

+ )} +

+ {title} +

+

{body}

+
+ {children} +
+ ); +} + +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(readDefaultTool); + const current = + (picked && installed.some((x) => x.id === picked) ? picked : null) ?? + aiTools.defaultId ?? + installed[0]?.id ?? + null; + const [mode, setMode] = useState(() => readDefaultMode() as DefaultMode); + + const pick = (id: string) => { + writeDefaultTool(id); + setPicked(id); + }; + + return ( + +
+

+ + {t("agent.detected", { count: installed.length })} +

+ {aiTools.loaded && !installed.length && ( +

{t("agent.none")}

+ )} +
+ {installed.map((tool) => { + const selected = tool.id === current; + return ( + + ); + })} + {more && + others.map((tool) => ( +
+ + + {tool.label} + {t("agent.notInstalled")} + +
+ ))} +
+ {others.length > 0 && ( + + )} +
+ +
+
+ {t("agent.permission")} + { + writeDefaultMode(m); + setMode(m); + }} + options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))} + /> +
+

{t("agent.permissionHint")}

+
+
+ ); +} + +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(readTerminalFont); + + return ( + +
+ {MODE_CARDS.map(({ mode: m, Icon }) => { + const selected = mode === m; + return ( + + ); + })} +
+ + {(["dark", "light"] as Theme[]).map((group) => ( +
+

+ {t(group === "dark" ? "look.darkThemes" : "look.lightThemes")} +

+
+ {THEMES[group].map((name) => { + const selected = themeFor[group] === name; + const label = THEME_LABELS[name] ?? ts("appearance.default"); + return ( + + ); + })} +
+
+ ))} + +
+
+

{t("look.language")}

+ void i18n.changeLanguage(v)} + options={[ + { value: "en" as const, label: ts("language.names.en") }, + { value: "vi" as const, label: ts("language.names.vi") }, + ]} + /> +
+
+

{t("look.font")}

+ { + writeTerminalFont(f); + setFont(f); + }} + options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({ + value: f, + label: ( + + {ts(`terminalFont.names.${f}`)} + + ), + }))} + /> +
+
+

{t("look.more")}

+
+ ); +} + +function NotifyStep() { + const { t } = useTranslation("onboarding"); + const { t: ts } = useTranslation("settings"); + const [prefs, setPrefs] = useState(loadNotifPrefs); + const [result, setResult] = useState(null); + const supported = typeof window !== "undefined" && "Notification" in window; + const [permission, setPermission] = useState(() => + supported ? Notification.permission : "default" + ); + const update = (patch: Partial) => { + 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 ( + +
+
+
+

{t("notify.enable")}

+

{t("notify.enableHint")}

+
+ void enable(v)} + label={t("notify.enable")} + /> +
+ {permission === "denied" && ( +

{t("notify.blocked")}

+ )} +
+ {t("notify.sound")} + + + +
+ {result && ( +

+ {result} +

+ )} +
+
+ ); +} diff --git a/client/src/components/onboarding/__tests__/Onboarding.test.tsx b/client/src/components/onboarding/__tests__/Onboarding.test.tsx new file mode 100644 index 00000000..9b6ee614 --- /dev/null +++ b/client/src/components/onboarding/__tests__/Onboarding.test.tsx @@ -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ĩ + */ + +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>(); + 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: () =>
, +})); + +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 {s.onboardedAt ?? "no"}; +} +const renderWizard = () => + render( + + + + + ); + +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(''); + try { + startTour(); + render( + + + + ); + expect(await screen.findByText("Create your first workspace")).toBeInTheDocument(); + // The dialog opened: the tour moves on by itself. + const dialog = mount( + '
' + ); + 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 = '
'; + 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(''); + try { + localStorage.setItem("agenthub.tour", "create"); + render( + + + + ); + expect(await screen.findByText("Create your first workspace")).toBeInTheDocument(); + } finally { + spy.mockRestore(); + page.remove(); + } + }); +}); diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index 0d35491a..ae1aee89 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -65,6 +65,7 @@ export function PaneChat({ rows={2} slash={tool === "claude"} fileCwd={cwd} + lane={runId} />
{t("chat.hint")} diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index ff55e832..27841a3d 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -163,6 +163,8 @@ interface PromptEditorProps { /** 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; } /** An attached file: its path on the server's machine, and a local preview @@ -187,6 +189,7 @@ export function PromptEditor({ onImagesChange, bare, onBusyChange, + lane, }: PromptEditorProps) { const { t } = useTranslation("run"); const taRef = useRef(null); @@ -325,7 +328,7 @@ export function PromptEditor({ 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) => ({ diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 9ec146d0..1d53c924 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -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(" ") + " "); diff --git a/client/src/components/run/__tests__/RunSetup.test.tsx b/client/src/components/run/__tests__/RunSetup.test.tsx index 028f97e1..6fc9988b 100644 --- a/client/src/components/run/__tests__/RunSetup.test.tsx +++ b/client/src/components/run/__tests__/RunSetup.test.tsx @@ -186,6 +186,7 @@ describe("PromptEditor — newlines and images", () => { fileCwd="/Users/tester" images={images} onImagesChange={setImages} + lane={7} /> {promptWithImages("see", images)} @@ -195,7 +196,8 @@ 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"); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 3a42570d..5b9e913e 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -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()); }); diff --git a/client/src/components/settings/AppearanceSettings.tsx b/client/src/components/settings/AppearanceSettings.tsx index baf6c3b0..dd4a3f90 100644 --- a/client/src/components/settings/AppearanceSettings.tsx +++ b/client/src/components/settings/AppearanceSettings.tsx @@ -10,6 +10,7 @@ * 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ĩ */ @@ -21,6 +22,7 @@ import { PetSettings } from "./PetSettings"; import { ChatUiSettings } from "./ChatUiSettings"; 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 +31,7 @@ import { type TerminalFont, } from "../../lib/terminalFont"; -const THEME_LABELS: Record = { +export const THEME_LABELS: Record = { darcula: "Darcula", "apple-dark": "Apple Dark", "apple-light": "Apple Light", @@ -44,7 +46,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 (
+ + + + {t("onboarding:rerun.button")} + + } + /> +
); } diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index b9e29c0d..074f5d47 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -115,6 +115,7 @@ export const i18nReady = i18n "splash", "lanes", "users", + "onboarding", ], defaultNS: "common", interpolation: { escapeValue: false }, diff --git a/client/src/i18n/locales/en/onboarding.json b/client/src/i18n/locales/en/onboarding.json new file mode 100644 index 00000000..1d5f8737 --- /dev/null +++ b/client/src/i18n/locales/en/onboarding.json @@ -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 here 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" + } +} diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 0fbc1033..edb75be1 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -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?", diff --git a/client/src/i18n/locales/vi/onboarding.json b/client/src/i18n/locales/vi/onboarding.json new file mode 100644 index 00000000..70efafbb --- /dev/null +++ b/client/src/i18n/locales/vi/onboarding.json @@ -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 vào đây để 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" + } +} diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 6e812cf7..0cbd1583 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -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?", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 174d093f..371e316c 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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; 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 @@ -675,13 +677,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("/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("/me/prefs"), + /** PUT /api/me/prefs — merge; `null` deletes a key. */ savePrefs: (prefs: Record) => - request("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }), + request("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }), + /** POST /api/me/onboarded — the first-run setup is done (or skipped). */ + onboarded: () => request("/me/onboarded", { method: "POST" }), }, // ───────────────────────── Server mode staff views (routes/users.js) ───────────────────────── users: { @@ -2448,13 +2452,19 @@ 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("/run", { method: "POST", body: JSON.stringify(args) }), @@ -3600,6 +3610,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_` — 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 +3665,9 @@ export interface AiProxyInput { models?: Partial; /** 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), @@ -4312,12 +4329,6 @@ export interface RunStartArgs { /** A lane's tmux-backed terminal run — one per lane, id is the tmux session * name (`agenthub-lane-`). */ -/** One account's own UI preferences (server/routes/me.js): known keys → string values. */ -export interface MePrefs { - prefs: Record; - onboardedAt: string | null; -} - export interface RunHandle { id: string; laneId: number | null; @@ -4528,7 +4539,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; } diff --git a/client/src/lib/sessionDefaults.ts b/client/src/lib/sessionDefaults.ts new file mode 100644 index 00000000..31f2b117 --- /dev/null +++ b/client/src/lib/sessionDefaults.ts @@ -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ĩ + */ + +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); +} diff --git a/client/src/lib/userPrefs.ts b/client/src/lib/userPrefs.ts new file mode 100644 index 00000000..a749a2ae --- /dev/null +++ b/client/src/lib/userPrefs.ts @@ -0,0 +1,182 @@ +/** + * @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ĩ + */ + +import { useSyncExternalStore } from "react"; +import { api } from "./api"; +import i18n from "../i18n"; + +/** 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(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, ...next }; + listeners.forEach((l) => l()); +}; + +let pending: Record = {}; +let timer: ReturnType | 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 { + 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) { + 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 { + 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 = {}; + 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 { + 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); +} diff --git a/client/src/pages/AiProxyConfig.tsx b/client/src/pages/AiProxyConfig.tsx index 46ab50a3..782768a1 100644 --- a/client/src/pages/AiProxyConfig.tsx +++ b/client/src/pages/AiProxyConfig.tsx @@ -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 & { +type FormState = Omit & { apiKey: string; behavesAs: string; models: Partial; + 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([]); const [error, setError] = useState(null); const [editing, setEditing] = useState(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) => ( ctx.open(e, item)} + onContextMenu={canWrite(item) ? (e) => ctx.open(e, item) : undefined} className="border-t border-border" > {item.builtin && } {item.label} + {item.scope === "personal" && ( + + {t("aiProxy.personal")} + + )} {item.builtin && ( @@ -277,7 +298,7 @@ export function AiProxyConfig() { {item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")} - {!item.builtin && ( + {canWrite(item) && (