Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa156d6a59 | |||
| ce773a3f5a | |||
| 4407b3bb16 | |||
| 44fd664670 | |||
| 118eaee2ec | |||
| 3ab750c765 | |||
| 84e2a9f0ac |
+223
-8
@@ -2853,8 +2853,8 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name]",
|
||||
"Claude account pool: new sessions spread over these accounts",
|
||||
"list|add|remove [name] [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -3952,6 +3952,23 @@ async function cmdServer(sub, flags) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "install-system") return installSystem(flags, settingsEnv);
|
||||
if (sub === "tools") {
|
||||
// S3/S4: copy this install's scripts and every installed AI tool under
|
||||
// /opt/agenthub, where every account's pane runs them (the server also
|
||||
// does this at start and after each install/update).
|
||||
const shared = require(path.join(REPO_ROOT, "server", "lib", "shared-tools.js"));
|
||||
await shared.publishApp(REPO_ROOT);
|
||||
const rows = await shared.publishAll();
|
||||
table(
|
||||
["tool", "shared copy"],
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
r.error ? c.red(r.error) : `${r.path}${r.copied ? "" : c.dim(" (unchanged)")}`,
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "off") {
|
||||
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
|
||||
envNote("AGENTHUB_MODE");
|
||||
@@ -3969,11 +3986,120 @@ async function cmdServer(sub, flags) {
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub server setup [--hostname name] [--admin name] | off | status")
|
||||
c.red(
|
||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub server install-system` — one Linux user per account (S3,
|
||||
* docs/SERVER-MODE.md). Run as the HOST account, never as root: it resolves
|
||||
* this install's paths as the host, hands them to the root half
|
||||
* (`scripts/server/install-system.sh`) through ONE `sudo`, points the system
|
||||
* unit at this install, and moves the dashboard from the `systemd --user`
|
||||
* unit to the system one (panes survive: KillMode=process). `--uninstall`
|
||||
* goes back to the user unit.
|
||||
*/
|
||||
async function installSystem(flags, settingsEnv) {
|
||||
const os = require("node:os");
|
||||
if (process.getuid && process.getuid() === 0) {
|
||||
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
|
||||
}
|
||||
const which = (bin) =>
|
||||
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
|
||||
const host = os.userInfo().username;
|
||||
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||
const data = getDataDir();
|
||||
const port = String(process.env.DASHBOARD_PORT || "4820");
|
||||
const script = path.join(REPO_ROOT, "scripts", "server", "install-system.sh");
|
||||
const args = flags.uninstall
|
||||
? [script, "--uninstall", "--host", host]
|
||||
: [
|
||||
script,
|
||||
...["--host", host, "--node", process.execPath, "--root", REPO_ROOT, "--data", data],
|
||||
...["--port", port, "--tmux", which("tmux"), "--git", which("git")],
|
||||
...(which("gh") ? ["--gh", which("gh")] : []),
|
||||
];
|
||||
if (flags["dry-run"]) {
|
||||
console.log(["sudo", "bash", ...args].join(" "));
|
||||
return;
|
||||
}
|
||||
const userUnit = (...a) => spawnSync("systemctl", ["--user", ...a, "agenthub.service"]);
|
||||
// What either unit runs: this install's node and server, with its env.
|
||||
const runtime = path.join(data, "runtime");
|
||||
const clientDist = fs.existsSync(path.join(REPO_ROOT, "client", "dist", "index.html"))
|
||||
? path.join(REPO_ROOT, "client", "dist")
|
||||
: path.join(runtime, "client-dist");
|
||||
const env = { NODE_ENV: "production", PATH: process.env.PATH || "", DASHBOARD_PORT: port };
|
||||
env.DASHBOARD_CLIENT_DIST = clientDist;
|
||||
if (!fs.existsSync(path.join(REPO_ROOT, "node_modules"))) {
|
||||
env.NODE_PATH = path.join(runtime, "node_modules");
|
||||
}
|
||||
const serverArgv = [process.execPath, path.join(REPO_ROOT, "server", "index.js")];
|
||||
if (flags.uninstall) {
|
||||
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
|
||||
throw new Error("install-system --uninstall failed");
|
||||
}
|
||||
// Repoint the user unit first: the build it named may have been pruned
|
||||
// while the system unit was the one protected.
|
||||
require(path.join(REPO_ROOT, "scripts", "systemd-service.js")).writeUserService({
|
||||
argv: serverArgv,
|
||||
env,
|
||||
});
|
||||
userUnit("enable", "--now");
|
||||
console.log(`${c.green("✔")} back on the systemd --user unit`);
|
||||
return;
|
||||
}
|
||||
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
|
||||
throw new Error("install-system failed — nothing was started");
|
||||
}
|
||||
// The unit reads node, the plugin root and the env from a file this account
|
||||
// owns (the bootstrap repoints it after an update without root).
|
||||
const sys = require(path.join(REPO_ROOT, "scripts", "systemd-system-service.js"));
|
||||
const wrote = sys.writeUserService({ argv: serverArgv, env });
|
||||
if (wrote === "failed" || wrote === "unavailable") {
|
||||
throw new Error(`could not write ${sys.envPath()} (${wrote})`);
|
||||
}
|
||||
settingsEnv.writeSetting(
|
||||
"AGENTHUB_USERS_ROOT",
|
||||
require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js")).serverConf().usersRoot
|
||||
);
|
||||
// Hand over: the user unit stops (its panes stay), and so does a dashboard
|
||||
// started without one (detached) — else the system unit finds the port
|
||||
// taken and the old process, without the S3 groups, keeps serving.
|
||||
userUnit("disable", "--now");
|
||||
const boot = require(path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"));
|
||||
const sysUp = () =>
|
||||
spawnSync("systemctl", ["is-active", "agenthub.service"], {
|
||||
encoding: "utf8",
|
||||
}).stdout.trim() === "active";
|
||||
// A re-run while the system unit serves: that server is the one to keep.
|
||||
if (!sysUp()) boot.stopDashboard();
|
||||
for (let i = 0; i < 50 && !sysUp() && boot.livePids().length; i++) {
|
||||
spawnSync("sleep", ["0.2"]);
|
||||
}
|
||||
if (!sysUp() && boot.livePids().length) {
|
||||
throw new Error("a dashboard is still running — stop it first");
|
||||
}
|
||||
if (spawnSync("sudo", ["-n", "systemctl", "enable", "agenthub.service"]).status !== 0) {
|
||||
console.log(c.yellow("! could not enable agenthub.service for boot"));
|
||||
}
|
||||
if (spawnSync("sudo", ["-n", "systemctl", "start", "--no-block", "agenthub.service"]).status) {
|
||||
throw new Error("could not start agenthub.service — `sudo systemctl status agenthub`");
|
||||
}
|
||||
spawnSync("sleep", ["3"]);
|
||||
if (!sysUp()) {
|
||||
throw new Error("agenthub.service did not stay up — `sudo journalctl -u agenthub -n 50`");
|
||||
}
|
||||
console.log(`${c.green("✔")} the dashboard now runs as the system unit agenthub.service`);
|
||||
console.log(" Give the existing users their Linux accounts: agenthub users provision --all");
|
||||
console.log(
|
||||
c.dim(" Log out and back in once: your own session joins groups agenthub/agenthub-proc.")
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
|
||||
async function cmdUsers(sub, positional, flags) {
|
||||
const { auth } = serverAuth();
|
||||
@@ -4026,6 +4152,37 @@ async function cmdUsers(sub, positional, flags) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "provision": {
|
||||
// S3: the Linux account of a user made before install-system (or after a
|
||||
// failed create). Idempotent: an existing member account is kept.
|
||||
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
|
||||
if (!linux.s3Enabled()) {
|
||||
throw new Error(
|
||||
"one Linux user per account is not installed (agenthub server install-system)"
|
||||
);
|
||||
}
|
||||
const names = flags.all
|
||||
? auth
|
||||
.listUsers()
|
||||
.filter((u) => !u.disabled)
|
||||
.map((u) => u.username)
|
||||
: (need(), [name]);
|
||||
const memberClaude = require(path.join(REPO_ROOT, "server", "lib", "member-claude.js"));
|
||||
for (const n of names) {
|
||||
// One user that cannot be provisioned (a name Linux refuses, a folder
|
||||
// already there) is reported and skipped — never the end of the run.
|
||||
try {
|
||||
console.log(`${c.green("✔")} ${linux.provision("create", n)}`);
|
||||
} catch (e) {
|
||||
console.error(c.red(`✖ ${n}: ${e.message}`));
|
||||
process.exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
// Opt-in plugin baseline (/etc/agenthub/plugin-baseline.json); prints per entry.
|
||||
await memberClaude.applyPluginBaseline(n);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "disable":
|
||||
case "enable":
|
||||
need();
|
||||
@@ -4035,21 +4192,75 @@ async function cmdUsers(sub, positional, flags) {
|
||||
default:
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
|
||||
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | provision <name>|--all | assign <workspace id> <name> | audit [--limit N]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub accounts … --tool codex` — the Codex account pool (server/lib/codex-pool.js). */
|
||||
async function cmdCodexAccounts(sub, name, flags) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (sub === "add") {
|
||||
let added;
|
||||
if (flags["api-key"]) {
|
||||
added = pool.addAccount(name, { apiKey: await askPassword(`OpenAI API key for ${name}: `) });
|
||||
} else if (typeof flags.from === "string") {
|
||||
// A `codex login` home (or its auth.json) — read here, on the host's shell.
|
||||
const from = path.resolve(flags.from);
|
||||
const file = fs.statSync(from).isDirectory() ? path.join(from, "auth.json") : from;
|
||||
added = pool.addAccount(name, { auth: JSON.parse(fs.readFileSync(file, "utf8")) });
|
||||
console.log(c.dim(` You can delete ${file} now; the pool keeps its own copy.`));
|
||||
} else {
|
||||
throw new Error(
|
||||
"usage: agenthub accounts add <name> --tool codex --api-key | --from <dir>\n" +
|
||||
" A ChatGPT account: `CODEX_HOME=$(mktemp -d) codex login` signed in as it, then --from that dir."
|
||||
);
|
||||
}
|
||||
console.log(`${c.green("✔")} ${added.name} (${added.kind}) added`);
|
||||
console.log(c.dim(" New Codex sessions now spread over the pool's accounts."));
|
||||
return;
|
||||
}
|
||||
if (sub === "remove") {
|
||||
pool.removeAccount(name);
|
||||
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||
return;
|
||||
}
|
||||
if (sub !== undefined && sub !== "list") {
|
||||
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool codex");
|
||||
}
|
||||
pool.resetQuota();
|
||||
table(
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
a.name,
|
||||
a.kind,
|
||||
a.livePanes,
|
||||
a.spend5h.toFixed(2),
|
||||
a.spend7d.toFixed(2),
|
||||
a.quota
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
a.full ? "full" : "ok",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||
async function cmdAccounts(sub, positional) {
|
||||
async function cmdAccounts(sub, positional, flags = {}) {
|
||||
serverAuth(); // the same settings + database the server reads
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||
const name = positional[0];
|
||||
if (sub === "add" || sub === "remove") {
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name>`);
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
|
||||
}
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
}
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||
@@ -4089,9 +4300,13 @@ async function cmdAccounts(sub, positional) {
|
||||
: "ok",
|
||||
])
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||
return;
|
||||
}
|
||||
console.error(c.red("✖ Usage: agenthub accounts list | add <name> | remove <name>"));
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -4121,7 +4336,7 @@ async function runCommand(argv) {
|
||||
case "users":
|
||||
return cmdUsers(positional[0], positional.slice(1), flags);
|
||||
case "accounts":
|
||||
return cmdAccounts(positional[0], positional.slice(1));
|
||||
return cmdAccounts(positional[0], positional.slice(1), flags);
|
||||
case "stats":
|
||||
return cmdStats();
|
||||
case "kanban":
|
||||
|
||||
+9
-9
@@ -30,7 +30,7 @@
|
||||
<title>AgentHub - SmartGift Solution</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
@@ -45,23 +45,23 @@
|
||||
<meta property="og:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta property="og:image" content="/og-image.svg" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/svg+xml" />
|
||||
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.svg" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:image" content="/og-image.png" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
|
||||
<meta name="twitter:creator" content="@smartgift" />
|
||||
|
||||
<!-- JSON-LD structured data -->
|
||||
@@ -70,7 +70,7 @@
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "AgentHub",
|
||||
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
|
||||
"description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows, macOS, Linux",
|
||||
"softwareVersion": "1.0.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 411 KiB |
+72
-86
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 30 KiB |
@@ -8,6 +8,9 @@
|
||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* The login page follows the OS's light/dark (`applySystemTheme`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,12 +18,17 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||
|
||||
@@ -114,6 +122,18 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
|
||||
// per account, so a second login in this browser brings that account's.
|
||||
const account =
|
||||
state?.mode === "local"
|
||||
? "local"
|
||||
: state?.mode === "server" && state.user
|
||||
? `u${state.user.id}`
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (account) void hydrate();
|
||||
}, [account]);
|
||||
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
@@ -129,9 +149,18 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
@@ -149,38 +178,83 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
|
||||
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||
<span>{t("auth.password")}</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="btn-primary w-full" disabled={busy}>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
||||
{t("auth.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
||||
</div>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file ImageLightbox.tsx
|
||||
* @description One image shown full size over the whole page; a click or
|
||||
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
||||
* layer or a header drawn over a pane can cover it — opened from inside a
|
||||
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
||||
* layer, under anything stacked above it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={alt}
|
||||
data-testid="image-lightbox"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,8 @@ import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
import { OnboardingTour } from "./onboarding/OnboardingTour";
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
@@ -172,6 +174,8 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
<OnboardingTour />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -27,12 +27,15 @@ export function PathPickButton({
|
||||
start,
|
||||
onPick,
|
||||
showLabel = false,
|
||||
buttonRef,
|
||||
}: {
|
||||
kind?: "dir" | "file";
|
||||
/** The field's current value — the picker opens there. */
|
||||
start?: string;
|
||||
onPick: (path: string) => void;
|
||||
showLabel?: boolean;
|
||||
/** Lets a field beside it open the same picker (a click on an empty path). */
|
||||
buttonRef?: Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -61,6 +64,7 @@ export function PathPickButton({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={busy}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Generic styled dropdown that replaces native `<select>` elements
|
||||
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
||||
* checkmark space inconsistently; this control aligns labels, flips above the
|
||||
* trigger when viewport space is tight, and marks the chosen option with accent
|
||||
* trigger when space is tight — in its split pane (`data-menu-bounds`, see
|
||||
* `menuRoom`) or else the viewport — and marks the chosen option with accent
|
||||
* color plus a Lucide check.
|
||||
*
|
||||
* ## Accessibility — WAI-ARIA "listbox button"
|
||||
@@ -74,6 +75,14 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
|
||||
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
||||
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||
* over anything crossing its edge — else the viewport. */
|
||||
export function menuRoom(el: Element, r: DOMRect): { above: number; below: number } {
|
||||
const box = el.closest("[data-menu-bounds]")?.getBoundingClientRect();
|
||||
return { above: r.top - (box?.top ?? 0), below: (box?.bottom ?? window.innerHeight) - r.bottom };
|
||||
}
|
||||
|
||||
/** Single option in a {@link Select} list. */
|
||||
export interface SelectOption<T extends string> {
|
||||
/** Stored value emitted via `onChange`. */
|
||||
@@ -144,9 +153,11 @@ export function Select<T extends string>({
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
setOpenUp(below < 288 && rect.top > below);
|
||||
const { above, below } = menuRoom(
|
||||
buttonRef.current,
|
||||
buttonRef.current.getBoundingClientRect()
|
||||
);
|
||||
setOpenUp(below < 288 && above > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
@@ -88,13 +89,6 @@ const NAV_KEYS = [
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
// owned by the brand, not vendored here — a machine with no network shows the
|
||||
// alt text instead.
|
||||
const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||
const RECENT_EVENTS_CAP = 8;
|
||||
@@ -496,6 +490,7 @@ export function Sidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
data-testid="sidebar-tree-rail"
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||
* live tab is opened from its session row. Live status comes from the shared
|
||||
* lane list (`useWorkLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane's status or session moves (at most every 3 s) and handed over by the
|
||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
|
||||
* analyst: EVERY workspace, grouped under its owner (`owner_name`; yours
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -25,10 +29,11 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../hooks/useThrottledValue";
|
||||
import {
|
||||
@@ -70,7 +75,12 @@ export function WorkspaceTree() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useWorkLanes();
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const { state: auth } = useAuth();
|
||||
const { seesAll, canEditLane } = usePermissions();
|
||||
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
// Server mode staff see every user's workspaces, so the list says whose.
|
||||
const grouped = auth.mode === "server" && seesAll;
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -132,10 +142,17 @@ export function WorkspaceTree() {
|
||||
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
|
||||
const toolFilter =
|
||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||
const sorted = useMemo(
|
||||
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||
[lanes, order, toolFilter]
|
||||
);
|
||||
const sorted = useMemo(() => {
|
||||
const list = sortByStoredOrder(lanes, order).filter(
|
||||
(l) => !toolFilter || l.tool === toolFilter
|
||||
);
|
||||
if (!grouped) return list;
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
|
||||
return [...list].sort(
|
||||
(a, b) => rank(a) - rank(b) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
|
||||
);
|
||||
}, [lanes, order, toolFilter, grouped, meId]);
|
||||
const q = query.trim().toLowerCase();
|
||||
const nameHit = (l: Lane) =>
|
||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||
@@ -312,42 +329,90 @@ export function WorkspaceTree() {
|
||||
)}
|
||||
|
||||
<div ref={listRef} className="space-y-px px-1">
|
||||
{sorted.map((lane, i) => (
|
||||
<div key={lane.id} className="relative">
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
select(lane.id);
|
||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
// A workspace found by its name shows all of its sessions.
|
||||
query={q && nameHit(lane) ? "" : query}
|
||||
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
||||
selectedSessionId={selected.lane === lane.id ? selected.session : null}
|
||||
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
|
||||
hidden={!visible(lane)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sorted.map((lane, i) => {
|
||||
const owner = ownerKey(lane);
|
||||
// A search opens every group, like it opens every workspace.
|
||||
const folded = grouped && !q && !!prefs.collapsedOwners[owner];
|
||||
const readOnly = !canEditLane(lane);
|
||||
const ownerLabel =
|
||||
lane.owner_id == null
|
||||
? t("tree.noOwner")
|
||||
: lane.owner_id === meId
|
||||
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
|
||||
: (lane.owner_name ?? `#${lane.owner_id}`);
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
return (
|
||||
<div key={lane.id} className="relative">
|
||||
{groupStart && groupShown && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-owner-${owner}`}
|
||||
aria-expanded={!folded}
|
||||
title={t("tree.ownerToggle", { name: ownerLabel })}
|
||||
onClick={() =>
|
||||
writeProjectTreePrefs({
|
||||
collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
|
||||
})
|
||||
}
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-1 text-left text-[11px] font-medium text-fg-muted hover:text-fg-primary ${i === 0 ? "" : "mt-2"}`}
|
||||
>
|
||||
{folded ? (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{lane.owner_name?.[0] ?? "?"}
|
||||
</span>
|
||||
<span className="truncate">{ownerLabel}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">
|
||||
{sorted.filter((l) => ownerKey(l) === owner).length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && !folded && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
readOnly={readOnly ? t("tree.readOnly", { name: ownerLabel }) : undefined}
|
||||
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
// A read-only workspace has no home to open: it only unfolds.
|
||||
if (readOnly) return setExpanded(lane.id, !expanded(lane.id));
|
||||
select(lane.id);
|
||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && !folded && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
readOnly={readOnly}
|
||||
// A workspace found by its name shows all of its sessions.
|
||||
query={q && nameHit(lane) ? "" : query}
|
||||
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
||||
selectedSessionId={selected.lane === lane.id ? selected.session : null}
|
||||
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
|
||||
hidden={!visible(lane)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dropAt === sorted.length && (
|
||||
<div className="relative">
|
||||
<DropLine />
|
||||
@@ -400,6 +465,9 @@ export function WorkspaceTree() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A workspace's owner group: its user id, or "none". */
|
||||
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||
|
||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||
function DropLine() {
|
||||
return (
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
|
||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||
* analyst reads everything but works only in their own workspaces, a
|
||||
* developer only ever sees (and so changes) their own.
|
||||
* developer only ever sees (and so changes) their own. The login page wears the
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const prefs = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
@@ -25,6 +27,7 @@ vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -52,6 +55,7 @@ const user = { id: 1, username: "mia", role: "developer" };
|
||||
beforeEach(() => {
|
||||
me.mockReset();
|
||||
login.mockReset();
|
||||
prefs.mockReset().mockResolvedValue({ prefs: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -107,15 +111,15 @@ describe("AuthGate", () => {
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
|
||||
const name = await screen.findByLabelText(/username/i);
|
||||
const pass = screen.getByLabelText(/password/i);
|
||||
const pass = screen.getByLabelText(/^password$/i);
|
||||
await userEvent.type(name, "mia");
|
||||
await userEvent.type(pass, "nope");
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
|
||||
await userEvent.type(pass, "member-pass-1");
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
|
||||
|
||||
@@ -126,6 +130,32 @@ describe("AuthGate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthGate theme", () => {
|
||||
it("wears the OS theme while signed out and the account's own once signed in", async () => {
|
||||
const html = document.documentElement;
|
||||
// The previous user's choice, left in this browser.
|
||||
localStorage.setItem("theme", "dark");
|
||||
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||
const mq = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
vi.stubGlobal("matchMedia", () => mq); // the OS is light
|
||||
me.mockResolvedValue({ mode: "server", user: null });
|
||||
prefs.mockResolvedValue({ prefs: { theme: "dark", "agenthub.theme.dark": "orca-dark" } });
|
||||
login.mockResolvedValue({ user });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
|
||||
await userEvent.type(await screen.findByLabelText(/username/i), "mia");
|
||||
expect(html.dataset.mode).toBe("light");
|
||||
expect(html.hasAttribute("data-theme")).toBe(false);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^password$/i), "member-pass-1");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await waitFor(() => expect(html.dataset.theme).toBe("orca-dark"));
|
||||
expect(html.dataset.mode).toBe("dark");
|
||||
expect(localStorage.getItem("agenthub.theme.dark")).toBe("orca-dark");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePermissions / useWorkLanes", () => {
|
||||
const as = async (state: unknown) => {
|
||||
cleanup();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Select.test.tsx
|
||||
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
|
||||
* listbox/option roles with aria-selected, aria-activedescendant tracking,
|
||||
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger.
|
||||
* Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a
|
||||
* list that flips up by the room in its split pane, not the window's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -90,4 +91,21 @@ describe("Select (listbox button)", () => {
|
||||
fireEvent.click(screen.getByText("Apple"));
|
||||
expect(onChange).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => {
|
||||
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
|
||||
render(
|
||||
<div data-menu-bounds data-testid="pane">
|
||||
<Select value="b" onChange={() => {}} options={OPTIONS} label="Fruit" />
|
||||
</div>
|
||||
);
|
||||
// A top pane 0–400 in a 2000px window; the trigger near its bottom.
|
||||
vi.spyOn(screen.getByTestId("pane"), "getBoundingClientRect").mockReturnValue(rect(0, 400));
|
||||
const button = screen.getByRole("button", { name: "Fruit" });
|
||||
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||||
vi.stubGlobal("innerHeight", 2000);
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @file WorkspaceTree.owners.test.tsx
|
||||
* @description Server mode, the sidebar workspace list for an analyst: every
|
||||
* user's workspaces, grouped under their owner (yours first, no owner last),
|
||||
* groups folding and remembered; another user's workspace is read only — no
|
||||
* menu, no new session, and a session opens its analysis page, never a pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
|
||||
const ME = { id: 2, username: "ana", role: "analyst" as const };
|
||||
|
||||
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
|
||||
({
|
||||
id,
|
||||
title,
|
||||
cwd: `/w/${id}`,
|
||||
kind: "adopted",
|
||||
tool: "claude",
|
||||
status: "idle",
|
||||
liveness: "idle",
|
||||
pipeline_nodes: [],
|
||||
owner_id: owner,
|
||||
owner_name: name,
|
||||
}) as unknown as Lane;
|
||||
|
||||
const lanes = [
|
||||
lane(1, 3, "bob", "Bob app"),
|
||||
lane(2, null, null, "Old"),
|
||||
lane(3, ME.id, "ana", "Mine"),
|
||||
lane(4, 1, "admin", "Admin site"),
|
||||
];
|
||||
|
||||
vi.mock("../AuthGate", () => ({
|
||||
useAuth: () => ({ state: { mode: "server", user: ME } }),
|
||||
usePermissions: () => ({
|
||||
isAdmin: false,
|
||||
seesAll: true,
|
||||
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
|
||||
sessions: {
|
||||
list: vi.fn(async () => ({
|
||||
sessions: [
|
||||
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
|
||||
] as Session[],
|
||||
total: 1,
|
||||
limit: 6,
|
||||
offset: 0,
|
||||
})),
|
||||
},
|
||||
run: { list: vi.fn(async () => ({ items: [] })) },
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { WorkspaceTree } from "../WorkspaceTree";
|
||||
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<WorkspaceTree />
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe("WorkspaceTree for an analyst", () => {
|
||||
it("groups every workspace under its owner: yours first, no owner last", async () => {
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
const order = screen
|
||||
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
|
||||
.map((el) => el.dataset.testid);
|
||||
expect(order).toEqual([
|
||||
"workspace-owner-2",
|
||||
"workspace-row-3",
|
||||
"workspace-owner-1",
|
||||
"workspace-row-4",
|
||||
"workspace-owner-3",
|
||||
"workspace-row-1",
|
||||
"workspace-owner-none",
|
||||
"workspace-row-2",
|
||||
]);
|
||||
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
||||
});
|
||||
|
||||
it("folds a group and remembers it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-owner-3"));
|
||||
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
|
||||
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
|
||||
"3": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps another user's workspace read only", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
// Yours keeps its controls; theirs has none.
|
||||
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
|
||||
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
|
||||
// Picking it unfolds it instead of opening a workspace home.
|
||||
await user.click(screen.getByTestId("workspace-open-1"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
|
||||
const list = await screen.findByTestId("workspace-sessions-1");
|
||||
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
|
||||
await user.click(within(list).getByTestId("workspace-session-s-1"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,11 @@
|
||||
* the message stream via MessageList. Combines a WebSocket subscription, a
|
||||
* visibility-gated polling fallback, and a manual refresh button so the view
|
||||
* stays caught up even when hooks miss frames or the user is mid-text-only
|
||||
* turn (no PreToolUse fires until Stop).
|
||||
* turn (no PreToolUse fires until Stop). The view sticks to its end while
|
||||
* the reader is there: anything that makes it taller — a new line, the live
|
||||
* step, an image loading, the pane shown again after being hidden by CSS
|
||||
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
||||
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -79,12 +83,21 @@ interface ConversationViewProps {
|
||||
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
||||
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||
fill?: boolean;
|
||||
/** Opens a file named in a turn's "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** The session runs in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
follow?: number;
|
||||
}
|
||||
|
||||
export function ConversationView({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
onOpenFile,
|
||||
live = false,
|
||||
follow = 0,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
@@ -105,6 +118,7 @@ export function ConversationView({
|
||||
const lastLineRef = useRef(0);
|
||||
const firstLineRef = useRef(0);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const fetchingRef = useRef(false);
|
||||
// When a fetch is in flight and a new trigger arrives (WS event, poll,
|
||||
@@ -392,6 +406,30 @@ export function ConversationView({
|
||||
}
|
||||
}, [hasMore, loadingHistory, loadHistory]);
|
||||
|
||||
// Stick to the end while the reader is there. Scroll events say whether
|
||||
// they are; a size change — of the content, or of the box itself when a
|
||||
// hidden pane is shown — never fires one, so it is watched here.
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!container || !content || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
observer.observe(container);
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// The reader sent a message: show it, and what follows, as it lands.
|
||||
useEffect(() => {
|
||||
if (!follow) return;
|
||||
isAtBottomRef.current = true;
|
||||
setAwayFromEnd(false);
|
||||
setShowNewMsg(false);
|
||||
scrollToBottom();
|
||||
}, [follow, scrollToBottom]);
|
||||
|
||||
// Auto-scroll to bottom after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
@@ -450,38 +488,42 @@ export function ConversationView({
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={contentRef}>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll-up for history hint */}
|
||||
{hasMore && !loadingHistory && !loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll-up for history hint */}
|
||||
{hasMore && !loadingHistory && !loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
↑ {t("conversation.scrollForOlder")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.emptyBody")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} loading={false} />
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.emptyBody")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude Desktop's round "back to the end" button */}
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
* transcript: alternating user / assistant rows with collapsible thinking
|
||||
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
|
||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||
* the Conversation tab on the Session detail page.
|
||||
* the Conversation tab on the Session detail page. An image the prompt box
|
||||
* attached reaches the transcript as its saved path; a user turn shows the
|
||||
* image instead (`GET /api/run/drop/:name`). Each assistant turn that changed
|
||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||
* when the caller can (`onOpenFile`). A thinking block that carries text is
|
||||
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
||||
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
||||
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||
* now — the call still running, else "Thinking…" — and for how long.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -53,7 +62,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { memo, useState, useMemo } from "react";
|
||||
import { memo, useEffect, useState, useMemo } from "react";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "../../i18n";
|
||||
import {
|
||||
@@ -69,6 +79,8 @@ import {
|
||||
Pencil,
|
||||
Workflow,
|
||||
Cog,
|
||||
FileText,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
|
||||
@@ -113,12 +125,16 @@ const SENDER_STYLES: Record<
|
||||
};
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
import { fmt, formatModelName } from "../../lib/format";
|
||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: TranscriptMessage[];
|
||||
loading: boolean;
|
||||
/** Opens a file named in the "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** The session is running in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
/** Build a map from tool_use id → tool_result for matching */
|
||||
@@ -135,6 +151,16 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
|
||||
return map;
|
||||
}
|
||||
|
||||
/** The whole prompt a message was answered from: `input_tokens` is only its
|
||||
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
|
||||
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||
return (
|
||||
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
|
||||
);
|
||||
}
|
||||
const usageTitle = (u: NonNullable<TranscriptMessage["usage"]>) =>
|
||||
`input ${fmt(u.input_tokens || 0)} · cache read ${fmt(u.cache_read_input_tokens || 0)} · cache write ${fmt(u.cache_creation_input_tokens || 0)} · output ${fmt(u.output_tokens || 0)}`;
|
||||
|
||||
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */
|
||||
function isSkillContent(text: string): boolean {
|
||||
return text.startsWith("Base directory for this skill:");
|
||||
@@ -390,34 +416,120 @@ type Item =
|
||||
| { kind: "row"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||
| { kind: "work"; key: string; blocks: TranscriptContent[] };
|
||||
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
||||
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
|
||||
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||
|
||||
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
||||
const items: Item[] = [];
|
||||
// The file-changing calls of the turn so far, closed into one card when the
|
||||
// human speaks again (or the list ends).
|
||||
let edits: TranscriptContent[] = [];
|
||||
const endTurn = (key: string) => {
|
||||
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
|
||||
edits = [];
|
||||
};
|
||||
messages.forEach((msg, idx) => {
|
||||
const key = keys[idx]!;
|
||||
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
|
||||
// Purely tool_result: shown inside its call's block.
|
||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
if (sender === "user") return items.push({ kind: "user", key, msg });
|
||||
if (sender === "user") {
|
||||
endTurn(key);
|
||||
return items.push({ kind: "user", key, msg });
|
||||
}
|
||||
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) {
|
||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||
if (block.type === "tool_use") {
|
||||
if (last?.kind === "work") last.blocks.push(block);
|
||||
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||
} else if (block.type === "text" && block.text?.trim()) {
|
||||
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
||||
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
|
||||
}
|
||||
});
|
||||
});
|
||||
endTurn(keys[keys.length - 1] ?? "end");
|
||||
return items;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading }: MessageListProps) {
|
||||
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
|
||||
* the lines added and removed. A call still running or failed is left out. */
|
||||
function EditedFiles({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
onOpenFile,
|
||||
}: {
|
||||
blocks: TranscriptContent[];
|
||||
toolResultMap: Map<string, TranscriptContent>;
|
||||
onOpenFile?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
// `counted`: a result carried its +/- lines (an older server sends none).
|
||||
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||
for (const b of blocks) {
|
||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||
const p = input.file_path ?? input.notebook_path;
|
||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||
f.added += r.diff?.added ?? 0;
|
||||
f.removed += r.diff?.removed ?? 0;
|
||||
f.counted ||= !!r.diff;
|
||||
files.set(p, f);
|
||||
}
|
||||
if (files.size === 0) return null;
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let counted = false;
|
||||
files.forEach((f) => {
|
||||
added += f.added;
|
||||
removed += f.removed;
|
||||
counted ||= f.counted;
|
||||
});
|
||||
const counts = (a: number, r: number, show = counted) =>
|
||||
show && (
|
||||
<span className="flex-shrink-0 font-mono text-xs">
|
||||
<span className="text-status-success">+{a}</span>{" "}
|
||||
<span className="text-status-danger">−{r}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
|
||||
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
|
||||
{counts(added, removed)}
|
||||
</div>
|
||||
{[...files].map(([p, f]) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
title={p}
|
||||
disabled={!onOpenFile}
|
||||
onClick={() => onOpenFile?.(p)}
|
||||
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate">{p.split("/").pop()}</span>
|
||||
{counts(f.added, f.removed, f.counted)}
|
||||
{onOpenFile && <ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const pending = useMemo(
|
||||
() => (live ? workingOn(messages, toolResultMap) : null),
|
||||
[live, messages, toolResultMap]
|
||||
);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||
|
||||
@@ -434,7 +546,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
||||
{items.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
@@ -453,14 +565,133 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||
case "work":
|
||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
return (
|
||||
<EditedFiles
|
||||
key={item.key}
|
||||
blocks={item.blocks}
|
||||
toolResultMap={toolResultMap}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No line for this long and no call running: the turn died with no end
|
||||
// written (a crash, a kill) — stop saying it works.
|
||||
const IDLE_AFTER_MS = 15 * 60_000;
|
||||
|
||||
/** What the model is doing while its turn is not over: the call still
|
||||
* waiting for its result (`block`), else thinking (`block: null`); `since`
|
||||
* is that line's time. Null once the turn ended. */
|
||||
export function workingOn(
|
||||
messages: TranscriptMessage[],
|
||||
results: Map<string, TranscriptContent>,
|
||||
now = Date.now()
|
||||
): { block: TranscriptContent | null; since: string | null } | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!;
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
// A rename marker or a harness line says nothing about the turn.
|
||||
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
|
||||
const since = msg.timestamp ?? null;
|
||||
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
|
||||
if (msg.type === "assistant") {
|
||||
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
|
||||
if (call?.id && !results.has(call.id)) return { block: call, since };
|
||||
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
|
||||
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
|
||||
}
|
||||
// A prompt or a tool result: the model answers it next — unless the user stopped it.
|
||||
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
|
||||
if (text.startsWith("[Request interrupted")) return null;
|
||||
return fresh ? { block: null, since } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Claude Desktop's live line: the step running now and for how long. */
|
||||
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const input = block?.input as Record<string, unknown> | undefined;
|
||||
const title = !block
|
||||
? t("conversation.working")
|
||||
: typeof input?.description === "string" && input.description
|
||||
? input.description
|
||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
{since && (
|
||||
<span className="flex-shrink-0 font-mono text-xs">
|
||||
{formatMs(now - parseDate(since).getTime())}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = "text-[10px] font-mono text-fg-muted";
|
||||
|
||||
// What the prompt box sends for an attached image: its saved path under the
|
||||
// drop dir (`<tmp>/agenthub-drops-<uid>/<16 hex>[-name].png`, server/routes/run.js).
|
||||
const DROP_IMAGE =
|
||||
/(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||
|
||||
/** A user turn's text without its dropped-image paths, and those images' names. */
|
||||
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||
const images: string[] = [];
|
||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||
images.push(name);
|
||||
return "";
|
||||
});
|
||||
return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
|
||||
}
|
||||
|
||||
function DropImage({ name }: { name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gone, setGone] = useState(false);
|
||||
const src = `/api/run/drop/${encodeURIComponent(name)}`;
|
||||
// Drops are pruned after a day: the name is all that is left to show.
|
||||
if (gone)
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-xs text-fg-muted">
|
||||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{name.replace(/^[0-9a-f]{16}-?/, "") || name}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="block overflow-hidden rounded-xl border border-border bg-surface-2 hover:border-accent/50"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
onError={() => setGone(true)}
|
||||
className="max-h-48 max-w-64 object-contain"
|
||||
/>
|
||||
</button>
|
||||
{open && <ImageLightbox src={src} alt={name} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The human's turn: a bubble on the right, its time on hover. A long one (a
|
||||
* paste, a compaction summary) shows its first lines until opened. */
|
||||
const LONG_BUBBLE = 800;
|
||||
@@ -468,35 +699,54 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
const { t } = useTranslation("sessions");
|
||||
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
|
||||
const [open, setOpen] = useState(false);
|
||||
const blocks = useMemo(
|
||||
() =>
|
||||
msg.content.map((block) =>
|
||||
block.type === "text" && block.text ? splitDropImages(block.text) : null
|
||||
),
|
||||
[msg.content]
|
||||
);
|
||||
const images = blocks.flatMap((b) => b?.images ?? []);
|
||||
const hasText = blocks.some((b) => b?.text);
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-user"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||
className="group flex flex-col items-end gap-1"
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
|
||||
long && !open
|
||||
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{msg.content.map((block, b) => {
|
||||
if (block.type !== "text" || !block.text) return null;
|
||||
// Plain typing reads as typed; anything structured keeps its rendering.
|
||||
const plain =
|
||||
!hasTuiTags(block.text) &&
|
||||
!isSkillContent(block.text) &&
|
||||
!isTaskNotification(block.text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{stripAnsi(block.text).trim()}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(block.text, b)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<div
|
||||
data-testid="chat-user-images"
|
||||
className="flex max-w-[85%] flex-wrap justify-end gap-2"
|
||||
>
|
||||
{images.map((name) => (
|
||||
<DropImage key={name} name={name} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasText && (
|
||||
<div
|
||||
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
|
||||
long && !open
|
||||
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{blocks.map((block, b) => {
|
||||
const text = block?.text;
|
||||
if (!text) return null;
|
||||
// Plain typing reads as typed; anything structured keeps its rendering.
|
||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{stripAnsi(text).trim()}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(text, b)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{long && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -540,8 +790,8 @@ const AssistantText = memo(function AssistantText({
|
||||
>
|
||||
{msg.model && <span>{formatModelName(msg.model)}</span>}
|
||||
{msg.usage && (
|
||||
<span>
|
||||
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
|
||||
<span title={usageTitle(msg.usage)}>
|
||||
↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
|
||||
@@ -592,6 +842,8 @@ const WORK_KIND: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
|
||||
const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"];
|
||||
|
||||
export function workLabel(blocks: TranscriptContent[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of blocks) {
|
||||
@@ -600,7 +852,9 @@ export function workLabel(blocks: TranscriptContent[]): string {
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
if (!counts.size) return i18n.t("sessions:conversation.thinking");
|
||||
// Claude Desktop's order: commands, edits, reads, … and other tools last.
|
||||
const text = [...counts]
|
||||
.sort(([a], [b]) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b))
|
||||
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
|
||||
.join(", ");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
@@ -751,8 +1005,11 @@ const MessageRow = memo(function MessageRow({
|
||||
</span>
|
||||
)}
|
||||
{msg.usage && (
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span
|
||||
title={usageTitle(msg.usage)}
|
||||
className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="text-status-success/70">↓ {fmt(promptTokens(msg.usage))}</span>
|
||||
<span className="text-fg-muted">·</span>
|
||||
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file ConversationView.follow.test.tsx
|
||||
* @description A chat sticks to its end: when it grows or is shown again
|
||||
* after being hidden (a size change, which fires no scroll event) it scrolls
|
||||
* to the newest while the reader is at the end, leaves a reader who scrolled
|
||||
* up alone, and a send (`follow`) brings it back to the end.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let resize: (() => void) | null = null;
|
||||
const original = globalThis.ResizeObserver;
|
||||
beforeEach(() => {
|
||||
resize = null;
|
||||
// @ts-expect-error test stub
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(cb: () => void) {
|
||||
resize = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = original;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** The scroll box, with the layout jsdom does not compute. */
|
||||
function box(height: number) {
|
||||
const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 });
|
||||
Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("ConversationView sticks to its end", () => {
|
||||
it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => {
|
||||
const view = render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const el = box(1000);
|
||||
// Shown again after display:none, or taller: only a resize says so.
|
||||
act(() => resize?.());
|
||||
expect(el.scrollTop).toBe(1000);
|
||||
|
||||
// The reader scrolls up: growing no longer pulls them down.
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 });
|
||||
act(() => resize?.());
|
||||
expect(el.scrollTop).toBe(100);
|
||||
|
||||
// They send a message: back to the end, and it sticks again.
|
||||
view.rerender(<ConversationView sessionId="s1" fill live follow={1} />);
|
||||
expect(el.scrollTop).toBe(1400);
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 });
|
||||
act(() => resize?.());
|
||||
expect(el.scrollTop).toBe(1600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @file MessageList.edits.test.tsx
|
||||
* @description Each assistant turn that changed files ends with Claude
|
||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||
* removed summed over the turn, a failed call left out, and a row click handed
|
||||
* to the caller.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = "2026-10-09T08:00:00.000Z";
|
||||
const user = (text: string): TranscriptMessage =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: at,
|
||||
content: [{ type: "text", text }],
|
||||
}) as TranscriptMessage;
|
||||
const call = (id: string, name: string, file_path: string): TranscriptMessage =>
|
||||
({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_use", id, name, input: { file_path } }],
|
||||
}) as TranscriptMessage;
|
||||
const result = (id: string, diff?: { added: number; removed: number }, is_error = false) =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_result", id, output: "ok", is_error, ...(diff ? { diff } : {}) }],
|
||||
}) as TranscriptMessage;
|
||||
|
||||
describe("the Edited N files card", () => {
|
||||
it("sums each file's lines over the turn and opens a file on click", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
messages={[
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 3, removed: 1 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
result("2", { added: 10, removed: 0 }),
|
||||
call("3", "Edit", "/w/a.ts"),
|
||||
result("3", { added: 2, removed: 2 }),
|
||||
call("4", "Edit", "/w/c.ts"),
|
||||
result("4", undefined, true),
|
||||
user("thanks"),
|
||||
call("5", "Read", "/w/a.ts"),
|
||||
result("5"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const cards = screen.getAllByTestId("edited-files");
|
||||
expect(cards).toHaveLength(1); // the second turn only read
|
||||
const card = cards[0]!;
|
||||
expect(within(card).getByText("Edited 2 files")).toBeTruthy();
|
||||
expect(within(card).getByText("+15")).toBeTruthy();
|
||||
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
||||
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file MessageList.images.test.tsx
|
||||
* @description An image the prompt box attached reaches the transcript as its
|
||||
* saved drop path; the user's turn shows the image (served by
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
||||
* other path as typed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, splitDropImages } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const SHOT = "366d0a87d12176a2-image.png";
|
||||
|
||||
describe("dropped images in a user turn", () => {
|
||||
it("splitDropImages takes drop-dir image paths out of the text", () => {
|
||||
expect(
|
||||
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
|
||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
|
||||
// Windows' temp dir, no prefix name.
|
||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||
"0123456789abcdef.webp",
|
||||
]);
|
||||
// Not a drop, or not an image: left as typed.
|
||||
for (const text of [
|
||||
"/home/me/shot.png",
|
||||
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
|
||||
]) {
|
||||
expect(splitDropImages(text)).toEqual({ text, images: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the image instead of its path, full size on click", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T08:00:00.000Z",
|
||||
content: [{ type: "text", text: `look at this /tmp/agenthub-drops-1001/${SHOT}` }],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("look at this");
|
||||
expect(bubble).not.toHaveTextContent("agenthub-drops");
|
||||
const img = screen.getByRole("img", { name: SHOT });
|
||||
expect(img.getAttribute("src")).toBe(`/api/run/drop/${SHOT}`);
|
||||
|
||||
fireEvent.click(img);
|
||||
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, workLabel } from "../MessageList";
|
||||
import { MessageList, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -76,5 +76,132 @@ describe("MessageList — sender attribution", () => {
|
||||
"Ran 2 commands, edited a file, read a file"
|
||||
);
|
||||
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
|
||||
// Desktop's fixed order, whatever ran first: other tools always last.
|
||||
expect(workLabel([use("mcp__x__y"), use("Read"), use("Bash")])).toBe(
|
||||
"Ran 1 command, read a file, used a tool"
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a thinking block's text as narration, between two groups", () => {
|
||||
const call = (id: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
|
||||
});
|
||||
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
|
||||
const say = (text: string) =>
|
||||
msg({ type: "assistant", sender: "assistant", content: [{ type: "thinking", text }] });
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[call("a"), result("a"), say("Found an issue."), say(""), call("b"), result("b")]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByTestId("chat-tools")).toHaveLength(2);
|
||||
expect(screen.getByText("Found an issue.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the whole prompt as ↓, not just its uncached tail", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
usage: {
|
||||
input_tokens: 2,
|
||||
output_tokens: 40,
|
||||
cache_read_input_tokens: 120000,
|
||||
cache_creation_input_tokens: 3000,
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/↓ 123/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
const messages = [
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Bash",
|
||||
input: { command: "npm test", description: "Run the full client suite" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Run the full client suite");
|
||||
expect(row).toHaveTextContent(/1m \ds/);
|
||||
// Its result arrived: nothing is running any more.
|
||||
rerender(
|
||||
<MessageList
|
||||
loading={false}
|
||||
live
|
||||
messages={[
|
||||
...messages,
|
||||
msg({ content: [{ type: "tool_result", id: "t1", output: "ok" }] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||
});
|
||||
|
||||
it("knows what a live turn is doing until it ends", () => {
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const prompt = msg({ type: "user", sender: "user", timestamp: at });
|
||||
const call = msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }],
|
||||
});
|
||||
const result = msg({
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_result", id: "t1", output: "ok" }],
|
||||
});
|
||||
const reply = (stop_reason?: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
});
|
||||
const results = (m: ReturnType<typeof msg>[]) =>
|
||||
new Map(
|
||||
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
|
||||
);
|
||||
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
|
||||
|
||||
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
|
||||
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
|
||||
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
|
||||
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
|
||||
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
|
||||
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
|
||||
const stopped = msg({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||
});
|
||||
expect(state([prompt, call, result, stopped])).toBeNull();
|
||||
// Silent for 15 minutes with nothing running: the turn died.
|
||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be).
|
||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
|
||||
* which a click into the field opens too.
|
||||
* The worktree lane returned is `status: "provisioning"`:
|
||||
* the route answers before the actual `git worktree add` runs, so this
|
||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||
@@ -29,6 +30,7 @@ import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -111,7 +113,14 @@ export function AddLaneModal({
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
const aiTools = useAiTools();
|
||||
const defaultTool = aiTools.defaultId ?? "claude";
|
||||
// The account's own pick (first-run setup), while it is installed and on.
|
||||
const ownTool = readDefaultTool();
|
||||
const defaultTool =
|
||||
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
|
||||
? ownTool
|
||||
: null) ??
|
||||
aiTools.defaultId ??
|
||||
"claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
// Installed + enabled, the default first.
|
||||
const toolChoices = aiTools.tools
|
||||
@@ -319,6 +328,7 @@ export function AddLaneModal({
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const browseRef = useRef<HTMLButtonElement>(null);
|
||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
@@ -355,6 +365,7 @@ export function AddLaneModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-tour="add-lane"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
|
||||
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||||
@@ -449,8 +460,15 @@ export function AddLaneModal({
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||
{/* A click into the path opens the picker, as Browse does (the
|
||||
OS's own where it can be); typing still works from the keyboard. */}
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
@@ -458,7 +476,12 @@ export function AddLaneModal({
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||
<PathPickButton
|
||||
start={sourceRepo}
|
||||
onPick={setSourceRepo}
|
||||
showLabel
|
||||
buttonRef={browseRef}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
@@ -695,6 +718,7 @@ export function AddLaneModal({
|
||||
type="button"
|
||||
onClick={done ? handleCancel : submit}
|
||||
disabled={busy || disabled}
|
||||
data-tour="add-lane-create"
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{done ? t("addLaneSetupClose") : t("add")}
|
||||
|
||||
@@ -66,6 +66,7 @@ export function FolderBrowseModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("browse.title")}
|
||||
data-tour="folder-browse"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||||
* mode switcher's Files button; at full width the sidebar)
|
||||
* mode switcher's Files button; at full width the sidebar);
|
||||
* a row dragged onto a prompt box attaches it as `@path`
|
||||
*
|
||||
* and three ranges: `work` (uncommitted, what the agent has touched right
|
||||
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
|
||||
* `commit` (one commit, reached from history).
|
||||
*
|
||||
* A file can also be opened from outside the panel (`openRequest`: a chat's
|
||||
* "Edited N files" card), the same way a click in the tree opens it.
|
||||
*
|
||||
* Only the overview polls. Review, file, history and browse are a deliberate
|
||||
* read of a frozen moment — re-fetching under the reader's scroll position is
|
||||
* worse than a slightly stale view, and those are the expensive shapes.
|
||||
@@ -121,6 +125,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
@@ -277,6 +282,8 @@ function TreeNodes({
|
||||
data-tree-ignored={e.ignored || undefined}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||
draggable
|
||||
onDragStart={(ev) => dragProjectPath(ev, e.dir ? `${e.path}/` : e.path)}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
aria-current={active === e.path ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||
@@ -792,18 +799,25 @@ function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
onNewLane,
|
||||
full = false,
|
||||
wide = full,
|
||||
onDirtyChange,
|
||||
openRequest = null,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
|
||||
openRequest?: { path: string; nonce: number } | null;
|
||||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
/** Show and act on that session's own worktree (`claude --worktree`)
|
||||
* instead of the workspace's folder. The Workspace keys the panel on it,
|
||||
* so a different session is a fresh panel. */
|
||||
session?: string;
|
||||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||
* wins over `session`, and is keyed on like it. */
|
||||
source?: number;
|
||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||
@@ -815,11 +829,11 @@ export function LaneChangesPanel({
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
// One read of the working copy, shared by the header and every write below:
|
||||
// a stage, a discard or a commit changes the counts the header shows.
|
||||
const [git, refreshGit] = useLaneGitFacts(laneId, session);
|
||||
const [git, refreshGit] = useLaneGitFacts(laneId, session, source);
|
||||
// A merge, cherry-pick or revert is open: no new one starts from history.
|
||||
const opOpen = !!(git?.available && git.merging);
|
||||
// Every git read and write below goes to the same tree as those facts.
|
||||
const target = gitTarget(laneId, session);
|
||||
const target = gitTarget(laneId, session, source);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
const comment = useCallback(
|
||||
(text: string) => {
|
||||
@@ -1232,6 +1246,11 @@ export function LaneChangesPanel({
|
||||
const show = (path: string) => {
|
||||
void confirmDrop().then((ok) => ok && showNow(path));
|
||||
};
|
||||
// A file named from outside the panel (a chat's "Edited N files" card).
|
||||
useEffect(() => {
|
||||
if (openRequest) show(openRequest.path);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per request
|
||||
}, [openRequest?.nonce]);
|
||||
// A file's history (Show History): the commits that touched it.
|
||||
const showHistory = (path: string) => {
|
||||
void confirmDrop().then((ok) => {
|
||||
@@ -2053,6 +2072,8 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openWhole(p)}
|
||||
draggable
|
||||
onDragStart={(ev) => dragProjectPath(ev, p)}
|
||||
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{p}
|
||||
@@ -2105,6 +2126,7 @@ export function LaneChangesPanel({
|
||||
<LaneGitHeader
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
git={git}
|
||||
onChanged={rereadAll}
|
||||
onNewLane={onNewLane}
|
||||
@@ -2487,7 +2509,9 @@ export function LaneChangesPanel({
|
||||
) : !data ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !data.available ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
|
||||
</p>
|
||||
) : files.length === 0 && mode !== "file" ? (
|
||||
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
|
||||
) : mode === "overview" ? (
|
||||
@@ -2678,6 +2702,7 @@ export function LaneChangesPanel({
|
||||
<LaneConflictView
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
path={openFile}
|
||||
onResolved={() => {
|
||||
refreshGit();
|
||||
|
||||
@@ -38,6 +38,7 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
|
||||
export function LaneConflictView({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
@@ -45,6 +46,8 @@ export function LaneConflictView({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
@@ -65,7 +68,7 @@ export function LaneConflictView({
|
||||
setChoices([]);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.gitFile(gitTarget(laneId, session), path, "work")
|
||||
.gitFile(gitTarget(laneId, session, source), path, "work")
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setFile(
|
||||
@@ -78,7 +81,7 @@ export function LaneConflictView({
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [laneId, session, path]);
|
||||
}, [laneId, session, source, path]);
|
||||
|
||||
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
||||
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
||||
@@ -102,15 +105,18 @@ export function LaneConflictView({
|
||||
run(async () => {
|
||||
if (!parts || !file?.sha) return;
|
||||
await api.lanes.gitSaveFile(
|
||||
gitTarget(laneId, session),
|
||||
gitTarget(laneId, session, source),
|
||||
path,
|
||||
applyChoices(parts, choices).join("\n"),
|
||||
file.sha
|
||||
);
|
||||
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
|
||||
await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", {
|
||||
path,
|
||||
side: "done",
|
||||
});
|
||||
});
|
||||
const takeAll = (side: "ours" | "theirs") =>
|
||||
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
|
||||
run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side }));
|
||||
|
||||
const choose = (i: number, c: ConflictChoice | undefined) =>
|
||||
setChoices((prev) => {
|
||||
|
||||
@@ -45,14 +45,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action =
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "switch"
|
||||
| "branch-create"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "publish";
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||
|
||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||
@@ -95,6 +88,7 @@ function Btn({
|
||||
export function LaneGitBar({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
@@ -103,6 +97,8 @@ export function LaneGitBar({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
@@ -165,7 +161,7 @@ export function LaneGitBar({
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
|
||||
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
@@ -218,7 +214,7 @@ export function LaneGitBar({
|
||||
|
||||
const refreshBranches = async () => {
|
||||
try {
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
|
||||
@@ -36,7 +36,9 @@ const GIT_REFRESH_MS = 30_000;
|
||||
export function useLaneGitFacts(
|
||||
laneId: number,
|
||||
/** Read that session's own worktree instead (`?session=`). */
|
||||
session?: string
|
||||
session?: string,
|
||||
/** Read that extra folder's repo instead (`?source=`). */
|
||||
source?: number
|
||||
): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
@@ -54,7 +56,7 @@ export function useLaneGitFacts(
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(gitTarget(laneId, session))
|
||||
.git(gitTarget(laneId, session, source))
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
@@ -73,7 +75,7 @@ export function useLaneGitFacts(
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, session, nonce]);
|
||||
}, [laneId, session, source, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
@@ -322,20 +324,23 @@ export function LaneGitCounts({
|
||||
export function LaneGitRailCounts({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
onShowAll,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: number;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const [git] = useLaneGitFacts(laneId, session);
|
||||
const [git] = useLaneGitFacts(laneId, session, source);
|
||||
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
@@ -343,6 +348,8 @@ export function LaneGitHeader({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
@@ -374,6 +381,7 @@ export function LaneGitHeader({
|
||||
<LaneGitBar
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
git={git}
|
||||
onChanged={onChanged}
|
||||
onNewLane={onNewLane}
|
||||
|
||||
@@ -173,6 +173,7 @@ export function SessionReplay({
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={session?.cwd || lane.cwd}
|
||||
lane={lane.id}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
|
||||
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
||||
* `ConsoleTabs.tsx`). Inside
|
||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
||||
* the box, like Claude Desktop's: the `+` menu (`ChatAddMenu`: upload, or
|
||||
* attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's
|
||||
* by default; its last row, **Open terminal**, is no AI — it starts a plain
|
||||
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||
@@ -59,6 +60,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { ChatAddMenu } from "../run/ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
@@ -85,6 +88,7 @@ export {
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
@@ -114,12 +118,15 @@ function HomeBody({
|
||||
lane,
|
||||
onMenuAction,
|
||||
onRenamed,
|
||||
onSourcesChange,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||
onMenuAction: (action: WorkspaceMenuAction) => void;
|
||||
/** Called after a rename, so the page re-reads the lane list. */
|
||||
onRenamed?: () => void;
|
||||
/** Called with the sources after a folder or file is added or removed. */
|
||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -148,6 +155,7 @@ function HomeBody({
|
||||
}
|
||||
}, [draftKey, prompt]);
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
const [proxy, setProxy] = useSessionProxy();
|
||||
const proxies = useAiProxies();
|
||||
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||||
@@ -173,7 +181,8 @@ function HomeBody({
|
||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
||||
// model only when its file names the flag, an effort never.
|
||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
||||
const [mode, setMode] = useState<PermissionMode>("auto");
|
||||
// The account's own default (first-run setup) — never bypass.
|
||||
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
@@ -308,7 +317,9 @@ function HomeBody({
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
setView(await fn());
|
||||
const next = await fn();
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
@@ -529,12 +540,22 @@ function HomeBody({
|
||||
rows={3}
|
||||
slash={claude}
|
||||
fileCwd={lane.cwd}
|
||||
lane={lane.id}
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
pickFiles={pickFiles}
|
||||
bare
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<ChatAddMenu
|
||||
cwd={lane.cwd}
|
||||
laneId={lane.id}
|
||||
skills={claude}
|
||||
onUpload={() => pickFiles.current?.()}
|
||||
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
<div data-testid="workspace-home-tool">
|
||||
<Select<string>
|
||||
variant="ghost"
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
||||
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
|
||||
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
|
||||
* dot's meaning is screen-reader text, and an idle dot is a ring.
|
||||
* dot's meaning is screen-reader text, and an idle dot is a ring. A `readOnly`
|
||||
* row (another user's workspace, for an analyst) has no menu, rename or `+`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Eye, MoreHorizontal, Plus } from "lucide-react";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
@@ -49,6 +50,7 @@ export function workspaceName(lane: Lane): string {
|
||||
export function WorkspaceRow({
|
||||
lane,
|
||||
index,
|
||||
readOnly,
|
||||
selected,
|
||||
expanded,
|
||||
renaming,
|
||||
@@ -63,6 +65,9 @@ export function WorkspaceRow({
|
||||
lane: Lane;
|
||||
/** Position in the list — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
/** Set (to its tooltip) for a workspace this user may only read: no menu,
|
||||
* no rename, no new session. */
|
||||
readOnly?: string;
|
||||
selected: boolean;
|
||||
expanded: boolean;
|
||||
renaming: boolean;
|
||||
@@ -82,7 +87,12 @@ export function WorkspaceRow({
|
||||
const openMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMenu(e.clientX, e.clientY);
|
||||
if (!readOnly) onMenu(e.clientX, e.clientY);
|
||||
};
|
||||
const startRename = () => {
|
||||
if (readOnly) return;
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
};
|
||||
const Chevron = expanded ? ChevronDown : ChevronRight;
|
||||
const hoverBtn =
|
||||
@@ -140,22 +150,18 @@ export function WorkspaceRow({
|
||||
aria-current={selected ? "true" : undefined}
|
||||
// A double-click's second click renames instead of opening again.
|
||||
onClick={(e) => e.detail < 2 && onSelect()}
|
||||
onDoubleClick={() => {
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
}}
|
||||
onDoubleClick={startRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
startRename();
|
||||
return;
|
||||
}
|
||||
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("row.hint")}`}
|
||||
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
@@ -163,7 +169,8 @@ export function WorkspaceRow({
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
{!renaming && !readOnly && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text.
|
||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -220,6 +222,7 @@ export function WorkspaceSessionList({
|
||||
selectedSessionId = null,
|
||||
onMatches,
|
||||
hidden = false,
|
||||
readOnly = false,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Every live dashboard pane, from `GET /api/run`. */
|
||||
@@ -235,6 +238,8 @@ export function WorkspaceSessionList({
|
||||
onMatches?: (count: number) => void;
|
||||
/** Mounted only to load and count, during a search that hides the row. */
|
||||
hidden?: boolean;
|
||||
/** A workspace this user may only read. */
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -315,7 +320,7 @@ export function WorkspaceSessionList({
|
||||
};
|
||||
}, [orphanIds]);
|
||||
const fresh =
|
||||
sessions === null || q
|
||||
sessions === null || q || readOnly
|
||||
? []
|
||||
: [
|
||||
...laneRuns.filter(
|
||||
@@ -492,6 +497,7 @@ export function WorkspaceSessionList({
|
||||
// A double-click's second click renames; it must not
|
||||
// navigate (a second history entry) on top of the first.
|
||||
if (e.detail > 1) return;
|
||||
if (readOnly) return navigate(`/sessions/${encodeURIComponent(s.id)}`);
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
@@ -501,19 +507,19 @@ export function WorkspaceSessionList({
|
||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||
);
|
||||
}}
|
||||
onDoubleClick={() => startRename(s)}
|
||||
onDoubleClick={() => !readOnly && startRename(s)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
if (e.key === "F2" && !readOnly) {
|
||||
e.preventDefault();
|
||||
startRename(s);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => sessionCtx.open(e, s)}
|
||||
onContextMenu={readOnly ? undefined : (e) => sessionCtx.open(e, s)}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||
sessionWorktreeName(lane, s)
|
||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||
: ""
|
||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||
}${readOnly ? "" : `\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
@@ -539,21 +545,23 @@ export function WorkspaceSessionList({
|
||||
</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
|
||||
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => void archive(s)}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
// Invisible, not `hidden`: a keyboard user must be able to
|
||||
// Tab onto it, and it shows when focused.
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => void archive(s)}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
// Invisible, not `hidden`: a keyboard user must be able to
|
||||
// Tab onto it, and it shows when focused.
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -582,6 +582,19 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the picker on every click into the path field", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
const field = document.getElementById("add-lane-repo")!;
|
||||
|
||||
await user.click(field);
|
||||
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
|
||||
await user.click(field);
|
||||
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
|
||||
});
|
||||
|
||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
|
||||
@@ -255,6 +255,36 @@ describe("LaneChangesPanel", () => {
|
||||
expect(view).toHaveTextContent("keep");
|
||||
});
|
||||
|
||||
it("opens a file asked for from outside (a chat's Edited files card), once per request", async () => {
|
||||
const view = render(
|
||||
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
vi.mocked(api.lanes.changes).mockClear();
|
||||
// The same request again changes nothing; a new nonce opens the next one.
|
||||
view.rerender(
|
||||
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
|
||||
);
|
||||
view.rerender(<LaneChangesPanel laneId={7} openRequest={{ path: "src/main.ts", nonce: 2 }} />);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "src/main.ts",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
expect(api.lanes.changes).not.toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ path: "server/lib/git-ops.js" })
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for every file's hunks in review mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @file OnboardingTour.tsx
|
||||
* @description The step-by-step spotlight tour after the first-run setup
|
||||
* (docs/plans/onboarding): it dims the page around ONE real control and says
|
||||
* what to do with it — Add workspace → the folder → Add → the prompt box →
|
||||
* the live session → done. The overlay never takes a click (the user works
|
||||
* the real UI); a step moves on when what it asked for has happened (the
|
||||
* dialog opened, the workspace exists, the session started), and steps back
|
||||
* when the user backed out (the dialog closed with nothing made). Targets are
|
||||
* existing `data-testid`s plus `data-tour` marks in AddLaneModal and
|
||||
* FolderBrowseModal. The
|
||||
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the
|
||||
* account and survives a reload; `done` ends it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
const KEY = "agenthub.tour";
|
||||
const EVENT = "agenthub-tour";
|
||||
const POLL_MS = 250;
|
||||
|
||||
type StepId = "add" | "folder" | "create" | "prompt" | "session" | "finish";
|
||||
const ORDER: StepId[] = ["add", "folder", "create", "prompt", "session", "finish"];
|
||||
|
||||
const $ = (sel: string) => document.querySelector(sel);
|
||||
/** An element that is on screen (a console collapsed with CSS has no box). */
|
||||
function shown(sel: string): Element | null {
|
||||
const el = $(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 ? el : null;
|
||||
}
|
||||
|
||||
const modalOpen = () => !!$('[data-tour="add-lane"]');
|
||||
const composer = () => shown('[data-testid="workspace-home-composer"]');
|
||||
const sessionLive = () =>
|
||||
!!new URLSearchParams(window.location.search).get("run") && !!shown('[data-testid="pane-grid"]');
|
||||
|
||||
/** Where each step points, and what moves it on (or back). */
|
||||
const STEPS: Record<
|
||||
StepId,
|
||||
{ target: () => Element | null; next?: () => StepId | null; info?: boolean }
|
||||
> = {
|
||||
add: {
|
||||
// The sidebar's +; folded to a rail, its tree icon (which unfolds it).
|
||||
target: () =>
|
||||
shown('[data-testid="workspace-tree-add"]') || shown('[data-testid="sidebar-tree-rail"]'),
|
||||
next: () => (modalOpen() ? "folder" : null),
|
||||
},
|
||||
folder: {
|
||||
// The folder browser while it is open: it sits over the field.
|
||||
target: () => $('[data-tour="folder-browse"]') || $('[data-tour="add-lane-folder"]'),
|
||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||
info: true,
|
||||
},
|
||||
create: {
|
||||
target: () => $('[data-tour="add-lane-create"]'),
|
||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||
},
|
||||
prompt: {
|
||||
target: composer,
|
||||
next: () => (sessionLive() ? "session" : null),
|
||||
},
|
||||
session: { target: () => shown('[data-testid="pane-grid"]'), info: true },
|
||||
finish: { target: () => null, info: true },
|
||||
};
|
||||
|
||||
function read(): StepId | null {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return (ORDER as string[]).includes(v ?? "") ? (v as StepId) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(v: StepId | "done") {
|
||||
try {
|
||||
localStorage.setItem(KEY, v);
|
||||
} catch {
|
||||
/* storage blocked: the tour just ends with the page */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
/** Begin the tour at its first step (the setup's last button). */
|
||||
export const startTour = () => write("add");
|
||||
|
||||
/** Mounted once in the layout; renders nothing unless a tour is running. */
|
||||
export function OnboardingTour() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [step, setStep] = useState<StepId | null>(read);
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
// The dialog the target sits in, if any: the card goes beside the dialog.
|
||||
const [box, setBox] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setStep(read());
|
||||
window.addEventListener(EVENT, sync);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
const tick = () => {
|
||||
const def = STEPS[step];
|
||||
const moved = def.next?.();
|
||||
if (moved && moved !== step) return write(moved);
|
||||
const el = def.target();
|
||||
let r = el ? el.getBoundingClientRect() : null;
|
||||
// The folder field's suggestions hang below it: light them too.
|
||||
const list = el?.querySelector("[data-cwd-suggestions]")?.getBoundingClientRect();
|
||||
if (r && list) {
|
||||
const top = Math.min(r.top, list.top);
|
||||
const left = Math.min(r.left, list.left);
|
||||
r = new DOMRect(
|
||||
left,
|
||||
top,
|
||||
Math.max(r.right, list.right) - left,
|
||||
Math.max(r.bottom, list.bottom) - top
|
||||
);
|
||||
}
|
||||
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
|
||||
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
|
||||
// Only when it moved: a new DOMRect every tick would re-render forever.
|
||||
setRect((prev) =>
|
||||
prev &&
|
||||
r &&
|
||||
prev.x === r.x &&
|
||||
prev.y === r.y &&
|
||||
prev.width === r.width &&
|
||||
prev.height === r.height
|
||||
? prev
|
||||
: r
|
||||
);
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [step, location]);
|
||||
|
||||
if (!step) return null;
|
||||
const n = ORDER.indexOf(step) + 1;
|
||||
const end = () => write("done");
|
||||
const advance = () => {
|
||||
if (step === "folder") return write("create");
|
||||
if (step === "session") return write("finish");
|
||||
end();
|
||||
};
|
||||
const lost = !rect && step !== "finish";
|
||||
|
||||
// Beside a narrow target (a field in a dialog: below it, the card would
|
||||
// cover the rest of the dialog), else under it when there is room, else
|
||||
// above; centred when there is no target (the finish, or the page moved).
|
||||
const W = 320;
|
||||
let style: React.CSSProperties = { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
|
||||
if (rect) {
|
||||
const right = (box ?? rect).right;
|
||||
const top = Math.min(Math.max(12, rect.top), window.innerHeight - 220);
|
||||
const left = Math.min(Math.max(12, rect.left), window.innerWidth - W - 12);
|
||||
if (rect.width < window.innerWidth / 2 && right + W + 24 < window.innerWidth) {
|
||||
style = { left: right + 16, top };
|
||||
} else if (rect.bottom + 180 < window.innerHeight) {
|
||||
style = { left, top: rect.bottom + 12 };
|
||||
} else {
|
||||
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[70]" data-testid="onboarding-tour">
|
||||
{rect ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute rounded-lg ring-2 ring-accent transition-all duration-200"
|
||||
style={{
|
||||
left: rect.left - 6,
|
||||
top: rect.top - 6,
|
||||
width: rect.width + 12,
|
||||
height: rect.height + 12,
|
||||
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.55)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
step === "finish" && <div aria-hidden className="absolute inset-0 bg-black/55" />
|
||||
)}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
aria-label={t(`tour.${step}.title`)}
|
||||
className="pointer-events-auto absolute w-80 space-y-2 rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
style={style}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
|
||||
{t("tour.progress", { n, total: ORDER.length })}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-fg-primary">{t(`tour.${step}.title`)}</p>
|
||||
<p className="text-xs leading-relaxed text-fg-secondary">{t(`tour.${step}.body`)}</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{step !== "finish" && (
|
||||
<button type="button" onClick={end} className="btn-ghost px-0 text-xs">
|
||||
{t("tour.skip")}
|
||||
</button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{lost && !location.pathname.startsWith("/workspace") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/workspace")}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("tour.goWorkspaces")}
|
||||
</button>
|
||||
)}
|
||||
{STEPS[step].info && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={advance}
|
||||
data-testid="onboarding-tour-next"
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{step === "finish" ? t("tour.done") : t("tour.next")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* @file OnboardingWizard.tsx
|
||||
* @description The first-run setup, once per ACCOUNT (docs/plans/onboarding;
|
||||
* modelled on Orca's welcome): 1 the default agent — the AI tools detected on
|
||||
* this machine, the rest behind "show more" — and the permission mode a new
|
||||
* session starts in (never bypass: that stays per session); 2 the look —
|
||||
* System / Dark / Light, a theme for each mode, language and terminal font;
|
||||
* 3 the corner pet (`PetSettings`); 4 notifications — permission, sound,
|
||||
* a test. Every choice is the same localStorage write Settings makes, so it
|
||||
* applies at once and is synced to the account (`lib/userPrefs.ts`). Shown
|
||||
* when the account has not been through it and has no workspace yet (one that
|
||||
* has is marked done silently), or when Settings asks for it again. The last
|
||||
* step hands over to the tour (`OnboardingTour`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Check, ChevronLeft, Monitor, Moon, Play, Sun, Zap } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { Switch } from "../Switch";
|
||||
import { SettingsSegmented } from "../settings/SettingsRow";
|
||||
import { PetSettings } from "../settings/PetSettings";
|
||||
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
||||
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
||||
import {
|
||||
DEFAULT_MODES,
|
||||
readDefaultMode,
|
||||
readDefaultTool,
|
||||
writeDefaultMode,
|
||||
writeDefaultTool,
|
||||
type DefaultMode,
|
||||
} from "../../lib/sessionDefaults";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
readTerminalFont,
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import {
|
||||
NOTIF_SOUNDS,
|
||||
loadNotifPrefs,
|
||||
saveNotifPrefs,
|
||||
type NotifPrefs,
|
||||
type NotifSound,
|
||||
} from "../../lib/notifPrefs";
|
||||
import { notify, playSound } from "../../lib/notify";
|
||||
import { subscribeToPush } from "../../lib/push";
|
||||
import { startTour } from "./OnboardingTour";
|
||||
|
||||
const STEPS = ["agent", "look", "pet", "notify"] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
/** Mounted once in the layout: decides whether this account sees the setup. */
|
||||
export function OnboardingWizard() {
|
||||
const { loaded, onboardedAt, forced } = useOnboarding();
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!loaded || onboardedAt) return setShow(false);
|
||||
if (forced) return setShow(true);
|
||||
let alive = true;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
// An account already at work never needed a welcome.
|
||||
if (r.lanes.length) void markOnboarded();
|
||||
else setShow(true);
|
||||
})
|
||||
.catch(() => {
|
||||
/* cannot tell: ask again next load rather than interrupt */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [loaded, onboardedAt, forced]);
|
||||
return show ? <Wizard /> : null;
|
||||
}
|
||||
|
||||
function Wizard() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const navigate = useNavigate();
|
||||
const [index, setIndex] = useState(0);
|
||||
const step: Step = STEPS[index] ?? "agent";
|
||||
const last = index === STEPS.length - 1;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef);
|
||||
|
||||
const skip = () => void markOnboarded();
|
||||
const next = () => {
|
||||
if (!last) return setIndex(index + 1);
|
||||
void markOnboarded();
|
||||
startTour();
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
// Ctrl/⌘+Enter continues, like Orca.
|
||||
const nextRef = useRef(next);
|
||||
nextRef.current = next;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
nextRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="onboarding-title"
|
||||
data-testid="onboarding-wizard"
|
||||
className="flex h-[min(820px,92vh)] w-full max-w-4xl flex-col rounded-2xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
>
|
||||
<div className="space-y-5 px-8 pt-7">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/favicon.svg" alt="" className="h-7 w-7" />
|
||||
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3" aria-hidden>
|
||||
{STEPS.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`h-1 w-9 rounded-full ${i <= index ? "bg-fg-primary" : "bg-surface-4"}`}
|
||||
/>
|
||||
))}
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t("progress", { n: index + 1, total: STEPS.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-6">
|
||||
{step === "agent" && <AgentStep />}
|
||||
{step === "look" && <LookStep />}
|
||||
{step === "pet" && (
|
||||
<StepHead title={t("pet.title")} body={t("pet.body")}>
|
||||
<PetSettings />
|
||||
</StepHead>
|
||||
)}
|
||||
{step === "notify" && <NotifyStep />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border px-8 py-4">
|
||||
<button type="button" onClick={skip} className="btn-ghost text-xs">
|
||||
{t("skip")}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(index - 1)}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
{t("back")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
data-testid="onboarding-next"
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{last ? t("finish") : t("continue")}
|
||||
<kbd className="ml-1 rounded border border-white/30 px-1 text-[10px] font-normal opacity-80">
|
||||
Ctrl ↵
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepHead({
|
||||
eyebrow,
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
{eyebrow && (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h2 id="onboarding-title" className="text-3xl font-bold text-fg-primary">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-fg-secondary">{body}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentStep() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const aiTools = useAiTools();
|
||||
const installed = aiTools.tools.filter((x) => x.installed && x.enabled);
|
||||
const others = aiTools.tools.filter((x) => !(x.installed && x.enabled));
|
||||
const [more, setMore] = useState(false);
|
||||
const [picked, setPicked] = useState<string | null>(readDefaultTool);
|
||||
const current =
|
||||
(picked && installed.some((x) => x.id === picked) ? picked : null) ??
|
||||
aiTools.defaultId ??
|
||||
installed[0]?.id ??
|
||||
null;
|
||||
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
|
||||
|
||||
const pick = (id: string) => {
|
||||
writeDefaultTool(id);
|
||||
setPicked(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepHead eyebrow={t("agent.eyebrow")} title={t("agent.title")} body={t("agent.body")}>
|
||||
<div className="space-y-3">
|
||||
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||
{t("agent.detected", { count: installed.length })}
|
||||
</p>
|
||||
{aiTools.loaded && !installed.length && (
|
||||
<p className="text-sm text-fg-muted">{t("agent.none")}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{installed.map((tool) => {
|
||||
const selected = tool.id === current;
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => pick(tool.id)}
|
||||
aria-pressed={selected}
|
||||
data-testid={`onboarding-agent-${tool.id}`}
|
||||
className={`relative flex items-center gap-3 rounded-xl border px-4 py-3.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10 ring-1 ring-accent/50"
|
||||
: "border-border bg-surface-2 hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-fg-primary">
|
||||
{tool.label}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-fg-muted">
|
||||
{tool.command || tool.id}
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute right-2.5 top-2.5 flex h-5 w-5 items-center justify-center rounded-full bg-accent-solid text-white">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{more &&
|
||||
others.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-3.5 opacity-60"
|
||||
>
|
||||
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-fg-primary">{tool.label}</span>
|
||||
<span className="block text-xs text-fg-muted">{t("agent.notInstalled")}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMore(!more)}
|
||||
className="text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{more ? t("agent.less") : `${t("agent.more", { count: others.length })} →`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl border border-border bg-surface-2 px-4 py-3.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-fg-primary">{t("agent.permission")}</span>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("agent.permission")}
|
||||
value={mode}
|
||||
onChange={(m) => {
|
||||
writeDefaultMode(m);
|
||||
setMode(m);
|
||||
}}
|
||||
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
||||
</div>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_CARDS: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "system", Icon: Monitor },
|
||||
{ mode: "dark", Icon: Moon },
|
||||
{ mode: "light", Icon: Sun },
|
||||
];
|
||||
|
||||
function LookStep() {
|
||||
const { t, i18n } = useTranslation("onboarding");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
const { mode, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [font, setFont] = useState<TerminalFont>(readTerminalFont);
|
||||
|
||||
return (
|
||||
<StepHead title={t("look.title")} body={t("look.body")}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{MODE_CARDS.map(({ mode: m, Icon }) => {
|
||||
const selected = mode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
aria-pressed={selected}
|
||||
data-testid={`onboarding-mode-${m}`}
|
||||
className={`rounded-xl border p-2.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
{m === "system" ? (
|
||||
<div className="grid grid-cols-2 overflow-hidden rounded-md">
|
||||
<Swatch mode="dark" name={themeFor.dark} />
|
||||
<Swatch mode="light" name={themeFor.light} />
|
||||
</div>
|
||||
) : (
|
||||
<Swatch mode={m} name={themeFor[m]} />
|
||||
)}
|
||||
<span className="flex items-center gap-1.5 px-1 pt-2 text-sm font-medium text-fg-primary">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`look.modes.${m}`)}
|
||||
<span className="ml-auto text-[11px] font-normal text-fg-muted">
|
||||
{t(`look.modeHints.${m}`)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(["dark", "light"] as Theme[]).map((group) => (
|
||||
<div key={group} className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? ts("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}: ${label}`}
|
||||
className={`rounded-lg border p-1 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1 text-[11px] text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">{t("look.language")}</p>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("look.language")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: ts("language.names.en") },
|
||||
{ value: "vi" as const, label: ts("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">{t("look.font")}</p>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("look.font")}
|
||||
value={font}
|
||||
onChange={(f) => {
|
||||
writeTerminalFont(f);
|
||||
setFont(f);
|
||||
}}
|
||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||
value: f,
|
||||
label: (
|
||||
<span style={{ fontFamily: TERMINAL_FONTS[f] }}>
|
||||
{ts(`terminalFont.names.${f}`)}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("look.more")}</p>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyStep() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
const [prefs, setPrefs] = useState<NotifPrefs>(loadNotifPrefs);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const supported = typeof window !== "undefined" && "Notification" in window;
|
||||
const [permission, setPermission] = useState(() =>
|
||||
supported ? Notification.permission : "default"
|
||||
);
|
||||
const update = (patch: Partial<NotifPrefs>) => {
|
||||
const next = { ...prefs, ...patch };
|
||||
saveNotifPrefs(next);
|
||||
setPrefs(next);
|
||||
};
|
||||
|
||||
const enable = async (on: boolean) => {
|
||||
if (!on) return update({ enabled: false });
|
||||
if (supported && Notification.permission !== "granted") {
|
||||
const perm = await Notification.requestPermission();
|
||||
setPermission(perm);
|
||||
if (perm !== "granted") return;
|
||||
}
|
||||
update({ enabled: true });
|
||||
await subscribeToPush().catch(() => {
|
||||
/* the local fallback still shows them */
|
||||
});
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
const r = await notify(ts("notifications.testTitle"), ts("notifications.testBody"));
|
||||
setResult(
|
||||
r.via === "push" || r.via === "native"
|
||||
? ts("notifications.testSent")
|
||||
: r.via === "local"
|
||||
? ts("notifications.testLocal")
|
||||
: r.via === "blocked"
|
||||
? ts("notifications.testBlocked")
|
||||
: ts("notifications.testFailed")
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepHead title={t("notify.title")} body={t("notify.body")}>
|
||||
<div className="space-y-4 rounded-xl border border-border bg-surface-2 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-fg-primary">{t("notify.enable")}</p>
|
||||
<p className="text-xs text-fg-muted">{t("notify.enableHint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={prefs.enabled}
|
||||
onChange={(v) => void enable(v)}
|
||||
label={t("notify.enable")}
|
||||
/>
|
||||
</div>
|
||||
{permission === "denied" && (
|
||||
<p className="text-xs text-status-danger">{t("notify.blocked")}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||
<span className="mr-2 text-sm text-fg-primary">{t("notify.sound")}</span>
|
||||
<select
|
||||
aria-label={t("notify.sound")}
|
||||
value={prefs.sound}
|
||||
onChange={(e) => {
|
||||
const sound = e.target.value as NotifSound;
|
||||
update({ sound });
|
||||
playSound(sound);
|
||||
}}
|
||||
className="select py-1 text-xs"
|
||||
>
|
||||
{NOTIF_SOUNDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{ts(`notifications.sound.${k}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => playSound(prefs.sound)}
|
||||
disabled={prefs.sound === "none"}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
{t("notify.preview")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void test()}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
{t("notify.test")}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<p role="status" className="text-xs text-fg-secondary">
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @file Onboarding.test.tsx
|
||||
* @description The first-run setup (docs/plans/onboarding): the account's
|
||||
* preferences are loaded into localStorage on sign-in and every later change
|
||||
* is sent back; the starting choices never hold bypass; the wizard shows only
|
||||
* for an account that is new to it, writes each pick like Settings does and
|
||||
* ends in the tour; the tour follows the real UI step by step.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const prefsMock = vi.fn();
|
||||
const saveMock = vi.fn();
|
||||
const onboardedMock = vi.fn();
|
||||
const lanesMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
me: {
|
||||
prefs: () => prefsMock(),
|
||||
savePrefs: (p: unknown) => saveMock(p),
|
||||
onboarded: () => onboardedMock(),
|
||||
},
|
||||
lanes: { list: () => lanesMock() },
|
||||
aiTools: {
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
list: async () => ({
|
||||
default: "claude",
|
||||
catalog: [],
|
||||
tools: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
command: "claude",
|
||||
installed: true,
|
||||
enabled: true,
|
||||
},
|
||||
{ id: "codex", label: "Codex", command: "codex", installed: true, enabled: true },
|
||||
{ id: "kimi", label: "Kimi Code", command: "kimi", installed: false, enabled: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../settings/PetSettings", () => ({
|
||||
PetSettings: () => <div data-testid="pet-settings" />,
|
||||
}));
|
||||
|
||||
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
||||
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
|
||||
import { OnboardingWizard } from "../OnboardingWizard";
|
||||
import { OnboardingTour, startTour } from "../OnboardingTour";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetUserPrefs();
|
||||
__resetAiTools();
|
||||
prefsMock.mockReset();
|
||||
saveMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
onboardedMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: "2026-10-08T00:00:00Z" });
|
||||
lanesMock.mockReset().mockResolvedValue({ lanes: [], counts: {} });
|
||||
});
|
||||
|
||||
describe("account preferences", () => {
|
||||
it("loads the account's values, sends up this browser's, then every change", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
localStorage.setItem("agenthub.terminal.font", "maple");
|
||||
localStorage.setItem("theme", "dark");
|
||||
prefsMock.mockResolvedValue({ prefs: { theme: "light" }, onboardedAt: null });
|
||||
await hydrate();
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(saveMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ "agenthub.terminal.font": "maple" })
|
||||
);
|
||||
saveMock.mockClear();
|
||||
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||
localStorage.setItem("unrelated.key", "x");
|
||||
localStorage.removeItem("agenthub.pet");
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(saveMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveMock).toHaveBeenCalledWith({
|
||||
"agenthub.theme.dark": "darcula",
|
||||
"agenthub.pet": null,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("never keeps bypass as the starting permission mode", () => {
|
||||
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||
expect(readDefaultMode()).toBe("auto");
|
||||
writeDefaultMode("bypassPermissions" as never);
|
||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
||||
writeDefaultMode("plan");
|
||||
expect(readDefaultMode()).toBe("plan");
|
||||
});
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const s = useOnboarding();
|
||||
return <span data-testid="onboarded">{s.onboardedAt ?? "no"}</span>;
|
||||
}
|
||||
const renderWizard = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<OnboardingWizard />
|
||||
<Probe />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("the setup wizard", () => {
|
||||
it("does not show before the account's preferences load, nor once done", async () => {
|
||||
renderWizard();
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: "2026-01-01T00:00:00Z" });
|
||||
await act(() => hydrate());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips itself for an account that already has a workspace", async () => {
|
||||
lanesMock.mockResolvedValue({ lanes: [{ id: 1 }], counts: {} });
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
});
|
||||
|
||||
it("walks four steps, saving each pick, and ends in the tour", async () => {
|
||||
const user = userEvent.setup();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await screen.findByTestId("onboarding-wizard");
|
||||
expect(screen.getByText("Pick your default agent")).toBeInTheDocument();
|
||||
await user.click(await screen.findByTestId("onboarding-agent-codex"));
|
||||
expect(localStorage.getItem("agenthub.defaultTool")).toBe("codex");
|
||||
// A tool that is not installed is not offered until asked for, and never pickable.
|
||||
expect(screen.queryByText("Kimi Code")).toBeNull();
|
||||
await user.click(screen.getByText(/Show 1 more agents/));
|
||||
expect(screen.getByText("Kimi Code").closest("button")).toBeNull();
|
||||
await user.click(screen.getByRole("radio", { name: "Plan" }));
|
||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("plan");
|
||||
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByText("Make it feel like home")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-mode-light"));
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
await user.click(screen.getByRole("button", { name: "Light theme: Canvas" }));
|
||||
expect(localStorage.getItem("agenthub.theme.light")).toBe("canvas");
|
||||
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByTestId("pet-settings")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByText("Set up notifications")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /Add your first workspace/ }));
|
||||
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("add");
|
||||
});
|
||||
|
||||
it("is skipped for good with Skip setup", async () => {
|
||||
const user = userEvent.setup();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await user.click(await screen.findByRole("button", { name: "Skip setup" }));
|
||||
await waitFor(() => expect(screen.getByTestId("onboarded").textContent).not.toBe("no"));
|
||||
expect(onboardedMock).toHaveBeenCalled();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tour", () => {
|
||||
const mount = (html: string) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
document.body.appendChild(host);
|
||||
return host;
|
||||
};
|
||||
// jsdom has no layout: give the targets a box.
|
||||
const boxed = () =>
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
x: 10,
|
||||
y: 10,
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 30,
|
||||
right: 110,
|
||||
bottom: 40,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
it("follows the real UI: add → folder → create → prompt", async () => {
|
||||
const spy = boxed();
|
||||
const user = userEvent.setup();
|
||||
const page = mount('<button data-testid="workspace-tree-add">+</button>');
|
||||
try {
|
||||
startTour();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace"]}>
|
||||
<OnboardingTour />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
||||
// The dialog opened: the tour moves on by itself.
|
||||
const dialog = mount(
|
||||
'<div data-tour="add-lane"><div data-tour="add-lane-folder"></div><button data-tour="add-lane-create"></button></div>'
|
||||
);
|
||||
expect(await screen.findByText("Choose the project folder")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-tour-next"));
|
||||
expect(screen.getByText("Create it")).toBeInTheDocument();
|
||||
// Created: the dialog is gone and the workspace's prompt box is there.
|
||||
dialog.remove();
|
||||
page.innerHTML = '<div data-testid="workspace-home-composer"></div>';
|
||||
expect(await screen.findByText("Start your first session")).toBeInTheDocument();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("prompt");
|
||||
await user.click(screen.getByRole("button", { name: "Skip tour" }));
|
||||
expect(screen.queryByTestId("onboarding-tour")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("done");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
|
||||
const spy = boxed();
|
||||
const page = mount('<button data-testid="workspace-tree-add">+</button>');
|
||||
try {
|
||||
localStorage.setItem("agenthub.tour", "create");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace"]}>
|
||||
<OnboardingTour />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* @file ChatAddMenu.tsx
|
||||
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
|
||||
* files (the prompt box's own picker), what is already there to attach —
|
||||
* Project files (a search of the box's folder, folders too:
|
||||
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
|
||||
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
|
||||
* `lib/promptAttach.ts`) —, Skills (the user's and the project's)
|
||||
* and Plugins → one plugin → the skills and commands it ships. Picking one
|
||||
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
||||
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
||||
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
||||
* and Manage opens the Claude config page on that tab.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
History,
|
||||
Paperclip,
|
||||
Plug,
|
||||
Plus,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
interface Lists {
|
||||
skills: Entry[];
|
||||
plugins: { name: string; items: Entry[] }[];
|
||||
}
|
||||
|
||||
const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name);
|
||||
|
||||
function loadLists(cwd: string): Promise<Lists> {
|
||||
return Promise.all([
|
||||
api.ccConfig.skills(undefined, cwd).catch(() => ({ items: [] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).then(([sk, pl]) => ({
|
||||
skills: sk.items
|
||||
.map((s) => ({ name: s.name, description: s.frontmatter?.description }))
|
||||
.filter((s, i, all) => all.findIndex((o) => o.name === s.name) === i)
|
||||
.sort(byName),
|
||||
plugins: pl.plugins
|
||||
.filter((p) => p.enabled !== false && p.contributes)
|
||||
.map((p) => ({
|
||||
name: p.name,
|
||||
items: [...p.contributes!.items.skills, ...p.contributes!.items.commands]
|
||||
.map((it) => ({ name: it.name, description: it.description ?? undefined }))
|
||||
.filter((it, i, all) => all.findIndex((o) => o.name === it.name) === i)
|
||||
.sort(byName),
|
||||
}))
|
||||
.filter((p, i, all) => p.items.length > 0 && all.findIndex((o) => o.name === p.name) === i)
|
||||
.sort(byName),
|
||||
}));
|
||||
}
|
||||
|
||||
const PANEL =
|
||||
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
const ROW =
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
|
||||
function Row({
|
||||
icon,
|
||||
label,
|
||||
more,
|
||||
active,
|
||||
onClick,
|
||||
onHover,
|
||||
title,
|
||||
testId,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
more?: boolean;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
onHover?: () => void;
|
||||
title?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onHover}
|
||||
className={`${ROW} ${active ? "bg-surface-3 text-fg-primary" : ""}`}
|
||||
>
|
||||
<span className="shrink-0 text-fg-muted">{icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{more && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-muted" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Project files: a search of the box's folder, shortest paths first. */
|
||||
function ProjectFiles({ cwd, onPick }: { cwd: string; onPick: (rel: string) => void }) {
|
||||
const { t } = useTranslation("run");
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<string[] | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const tid = window.setTimeout(() => {
|
||||
api.run
|
||||
.files(cwd, q, true)
|
||||
.then((r) => live && setItems(r.items))
|
||||
.catch(() => live && setItems([]));
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(tid);
|
||||
};
|
||||
}, [cwd, q]);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t("chat.searchFiles")}
|
||||
data-testid="chat-add-project-search"
|
||||
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none"
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{!items || items.length === 0 ? (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{items ? t("chat.none") : "…"}</p>
|
||||
) : (
|
||||
items.map((p) => (
|
||||
<Row
|
||||
key={p}
|
||||
icon={
|
||||
p.endsWith("/") ? (
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
label={p}
|
||||
title={p}
|
||||
testId={`chat-add-project-${p}`}
|
||||
onClick={() => onPick(p)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Sub = "project" | "context" | "recent" | "skills" | "plugins";
|
||||
|
||||
export function ChatAddMenu({
|
||||
cwd,
|
||||
laneId = null,
|
||||
skills,
|
||||
onUpload,
|
||||
onInsert,
|
||||
onAttach,
|
||||
}: {
|
||||
cwd: string;
|
||||
/** The workspace, for its Context files; none hides them. */
|
||||
laneId?: number | null;
|
||||
/** Offer Skills and Plugins: Claude only, they are Claude Code's. */
|
||||
skills: boolean;
|
||||
onUpload: () => void;
|
||||
/** `/<name>` picked. */
|
||||
onInsert: (command: string) => void;
|
||||
/** An existing file picked: a project path, a context file, an upload. */
|
||||
onAttach: (items: Attachment[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sub, setSub] = useState<Sub | null>(null);
|
||||
const [context, setContext] = useState<Attachment[] | null>(null);
|
||||
const [recent, setRecent] = useState<Attachment[]>([]);
|
||||
const [plugin, setPlugin] = useState<string | null>(null);
|
||||
const [lists, setLists] = useState<Lists | null>(null);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (skills && !lists) void loadLists(cwd).then(setLists);
|
||||
|
||||
const away = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", away);
|
||||
document.addEventListener("keydown", esc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", away);
|
||||
document.removeEventListener("keydown", esc);
|
||||
};
|
||||
}, [open, skills, lists, cwd]);
|
||||
|
||||
// Read again at each opening: either list may have grown since.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setRecent(recentUploads());
|
||||
setContext(null);
|
||||
if (laneId === null) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(laneId)
|
||||
.then(
|
||||
(v) =>
|
||||
live &&
|
||||
setContext(
|
||||
v.sources
|
||||
.filter((s) => s.kind === "file" && !s.missing)
|
||||
.map((s) => ({ path: `@${s.path}`, name: s.name }))
|
||||
)
|
||||
)
|
||||
.catch(() => live && setContext([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [open, laneId]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setSub(null);
|
||||
setPlugin(null);
|
||||
};
|
||||
const pick = (command: string) => {
|
||||
close();
|
||||
onInsert(command);
|
||||
};
|
||||
const attach = (item: Attachment) => {
|
||||
close();
|
||||
onAttach([item]);
|
||||
};
|
||||
const manage = (tab: "skills" | "plugins") => {
|
||||
close();
|
||||
navigate(`/cc-config?tab=${tab}`);
|
||||
};
|
||||
|
||||
const entries = (items: Entry[], prefix: string, from?: string) =>
|
||||
items.length === 0 ? (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{lists ? t("chat.none") : "…"}</p>
|
||||
) : (
|
||||
items.map((it) => (
|
||||
<Row
|
||||
key={it.name}
|
||||
icon={<ScrollText className="h-4 w-4" />}
|
||||
label={it.name}
|
||||
title={[it.description, from].filter(Boolean).join("\n") || undefined}
|
||||
testId={`chat-add-skill-${prefix}${it.name}`}
|
||||
onClick={() => pick(`/${prefix}${it.name}`)}
|
||||
/>
|
||||
))
|
||||
);
|
||||
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-add"
|
||||
aria-label={t("chat.add")}
|
||||
title={t("chat.add")}
|
||||
aria-expanded={open}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
||||
<Row
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
label={t("chat.upload")}
|
||||
testId="chat-add-upload"
|
||||
onHover={() => setSub(null)}
|
||||
onClick={() => {
|
||||
close();
|
||||
onUpload();
|
||||
}}
|
||||
/>
|
||||
<Row
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
label={t("chat.projectFiles")}
|
||||
more
|
||||
active={sub === "project"}
|
||||
testId="chat-add-project"
|
||||
onHover={() => setSub("project")}
|
||||
onClick={() => setSub(sub === "project" ? null : "project")}
|
||||
/>
|
||||
{laneId !== null && (
|
||||
<Row
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
label={t("chat.contextFiles")}
|
||||
more
|
||||
active={sub === "context"}
|
||||
testId="chat-add-context"
|
||||
onHover={() => setSub("context")}
|
||||
onClick={() => setSub(sub === "context" ? null : "context")}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
icon={<History className="h-4 w-4" />}
|
||||
label={t("chat.recentUploads")}
|
||||
more
|
||||
active={sub === "recent"}
|
||||
testId="chat-add-recent"
|
||||
onHover={() => setSub("recent")}
|
||||
onClick={() => setSub(sub === "recent" ? null : "recent")}
|
||||
/>
|
||||
{skills && (
|
||||
<>
|
||||
<Row
|
||||
icon={<ScrollText className="h-4 w-4" />}
|
||||
label={t("chat.skills")}
|
||||
more
|
||||
active={sub === "skills"}
|
||||
testId="chat-add-skills"
|
||||
onHover={() => setSub("skills")}
|
||||
onClick={() => setSub(sub === "skills" ? null : "skills")}
|
||||
/>
|
||||
<Row
|
||||
icon={<Plug className="h-4 w-4" />}
|
||||
label={t("chat.plugins")}
|
||||
more
|
||||
active={sub === "plugins"}
|
||||
testId="chat-add-plugins"
|
||||
onHover={() => setSub("plugins")}
|
||||
onClick={() => setSub(sub === "plugins" ? null : "plugins")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sub === "project" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
||||
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sub === "context" || sub === "recent") && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{(sub === "context" ? context : recent)?.length ? (
|
||||
(sub === "context" ? context! : recent).map((it) => (
|
||||
<Row
|
||||
key={it.path}
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
label={it.name}
|
||||
title={it.path.replace(/^@/, "")}
|
||||
testId={`chat-add-${sub}-${it.name}`}
|
||||
onClick={() => attach(it)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">
|
||||
{sub === "context" && !context ? "…" : t("chat.none")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub === "skills" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Row
|
||||
icon={<Settings2 className="h-4 w-4" />}
|
||||
label={t("chat.manageSkills")}
|
||||
onClick={() => manage("skills")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub === "plugins" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{lists && lists.plugins.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
||||
)}
|
||||
{!lists && <p className="px-3 py-1.5 text-xs text-fg-muted">…</p>}
|
||||
{lists?.plugins.map((p) => (
|
||||
<Row
|
||||
key={p.name}
|
||||
icon={<Plug className="h-4 w-4" />}
|
||||
label={p.name}
|
||||
more
|
||||
active={plugin === p.name}
|
||||
testId={`chat-add-plugin-${p.name}`}
|
||||
onHover={() => setPlugin(p.name)}
|
||||
onClick={() => setPlugin(plugin === p.name ? null : p.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Row
|
||||
icon={<Settings2 className="h-4 w-4" />}
|
||||
label={t("chat.managePlugins")}
|
||||
onClick={() => manage("plugins")}
|
||||
/>
|
||||
{shownPlugin && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{entries(
|
||||
shownPlugin.items,
|
||||
`${shownPlugin.name}:`,
|
||||
t("chat.fromPlugin", { name: shownPlugin.name })
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @file ChatFilePreview.tsx
|
||||
* @description A file a Chat pane's session touched, shown the way Claude
|
||||
* Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the
|
||||
* content) and `FileListModal` (a list on the left, the picked file's preview
|
||||
* on the right — Claude's Uploads dialog). Content comes only through routes
|
||||
* that already guard it: a file inside the workspace through
|
||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
||||
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
|
||||
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
export interface PreviewScope {
|
||||
laneId: number | null;
|
||||
/** The workspace's own folder. */
|
||||
laneCwd: string;
|
||||
/** The pane's folder — the workspace's, or the session's own worktree. */
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||
const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
|
||||
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||
export const extOf = (p: string) => {
|
||||
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
|
||||
return m ? m[1]!.toUpperCase() : "";
|
||||
};
|
||||
/** An attached file's name without the drop dir's random prefix. */
|
||||
export const displayName = (p: string) =>
|
||||
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
||||
|
||||
type Content =
|
||||
| { kind: "loading" }
|
||||
| { kind: "image"; src: string }
|
||||
| { kind: "text"; text: string; truncated: boolean }
|
||||
| { kind: "none" };
|
||||
|
||||
function useContent(path: string, scope: PreviewScope): Content {
|
||||
const [content, setContent] = useState<Content>({ kind: "loading" });
|
||||
useEffect(() => {
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
setContent(
|
||||
IMAGE_RE.test(path)
|
||||
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
|
||||
: { kind: "none" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
const show = (p: Promise<Content>) => {
|
||||
setContent({ kind: "loading" });
|
||||
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
|
||||
};
|
||||
const base = scope.cwd.replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
|
||||
// Claude Code's own files (memory): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
|
||||
show(
|
||||
api.ccConfig
|
||||
.file(path)
|
||||
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
||||
);
|
||||
} else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const rel = path.slice(base.length + 1);
|
||||
const target =
|
||||
base === scope.laneCwd.replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
if (IMAGE_RE.test(path)) {
|
||||
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
||||
return;
|
||||
}
|
||||
show(
|
||||
api.lanes
|
||||
.gitFile(target, rel)
|
||||
.then((f) =>
|
||||
f.available && !f.binary
|
||||
? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated }
|
||||
: { kind: "none" }
|
||||
)
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]);
|
||||
return content;
|
||||
}
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
const { t } = useTranslation("run");
|
||||
const content = useContent(path, scope);
|
||||
if (content.kind === "loading") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "image") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||
<img
|
||||
src={content.src}
|
||||
alt={baseName(path)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "none") {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
<p>{t("chat.outline.noPreview")}</p>
|
||||
<p className="break-all font-mono text-xs">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{isMarkdown(path) ? (
|
||||
<MarkdownPreview text={content.text} className="p-4" />
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
||||
{content.text}
|
||||
</pre>
|
||||
)}
|
||||
{content.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
||||
export function FilePreview({
|
||||
path,
|
||||
scope,
|
||||
onClose,
|
||||
className = "",
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
return (
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
||||
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
||||
</span>
|
||||
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
aria-label={t("chat.outline.copyPath", { path })}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title={t("chat.outline.close")}
|
||||
aria-label={t("chat.outline.close")}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} scope={scope} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
||||
export function FileListModal({
|
||||
title,
|
||||
paths,
|
||||
scope,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
paths: string[];
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState(paths[0] ?? "");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
// Into document.body, like ImageLightbox: a pane's own stacking layer
|
||||
// would otherwise draw over a `fixed` overlay opened from inside it.
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-0/70 p-6"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
data-testid="chat-file-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
>
|
||||
<div className="flex w-80 flex-shrink-0 flex-col border-r border-border">
|
||||
<h2 className="px-5 pb-3 pt-5 text-lg text-fg-primary">{title}</h2>
|
||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||
{paths.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(p)}
|
||||
title={p}
|
||||
aria-current={p === picked}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 truncate">{displayName(p)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{picked && <FilePreview path={picked} scope={scope} onClose={onClose} className="flex-1" />}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @file ChatOutline.tsx
|
||||
* @description The right-hand panel of a Chat pane shown alone, laid out like
|
||||
* Claude Desktop's activity panel: the session's Progress (its
|
||||
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
|
||||
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
||||
* this session" — the files attached to its prompts, the files it read and the
|
||||
* memory files it used (a click opens the list with a preview), its skills
|
||||
* and MCP servers (a click opens the Claude config page). Read from
|
||||
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
|
||||
* uses for the collapsed button's count.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSearch,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
History,
|
||||
Paperclip,
|
||||
Plug,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type SessionOutline } from "../../lib/api";
|
||||
import { displayName, extOf } from "./ChatFilePreview";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const EMPTY: SessionOutline = {
|
||||
progress: [],
|
||||
outputs: [],
|
||||
files: [],
|
||||
uploads: [],
|
||||
memory: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
};
|
||||
|
||||
/** The session's outline, re-read on a visibility-gated poll. */
|
||||
export function useSessionOutline(sessionId: string): SessionOutline {
|
||||
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.outline(sessionId)
|
||||
.then((o) => live && setOutline({ ...EMPTY, ...o }))
|
||||
.catch(() => {});
|
||||
void load();
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
return outline;
|
||||
}
|
||||
|
||||
export type OutlineList = "uploads" | "files" | "memory";
|
||||
|
||||
function fileIcon(path: string) {
|
||||
const ext = extOf(path).toLowerCase();
|
||||
if (["xlsx", "xls", "csv", "tsv"].includes(ext)) return FileSpreadsheet;
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return FileImage;
|
||||
if (
|
||||
/^(c|cc|cpp|cs|css|go|h|html|java|js|jsx|json|kt|mjs|cjs|php|py|rb|rs|sh|sql|swift|ts|tsx|vue|ya?ml|toml)$/.test(
|
||||
ext
|
||||
)
|
||||
)
|
||||
return FileCode;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
count,
|
||||
action,
|
||||
grow,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
action?: ReactNode;
|
||||
/** Takes the panel's free height and scrolls inside it. */
|
||||
grow?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<section className={`flex flex-col px-3 pt-3 ${grow && open ? "min-h-0 flex-1" : ""}`}>
|
||||
<div className="flex items-center gap-1.5 px-1 pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-fg-primary"
|
||||
>
|
||||
{title}
|
||||
{count ? <span className="text-xs font-normal text-fg-muted">{count}</span> : null}
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 text-fg-muted transition-transform ${open ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="ml-auto flex items-center">{action}</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
className={
|
||||
grow
|
||||
? "min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,transparent,black_8px,black_calc(100%-8px),transparent)]"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const rowClass =
|
||||
"flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-[13px] text-fg-secondary enabled:hover:bg-surface-3";
|
||||
|
||||
/** A "Used in this session" row: icon, label, the first names, +N. */
|
||||
function UsedRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
names,
|
||||
onClick,
|
||||
to,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
names: string[];
|
||||
onClick?: () => void;
|
||||
to?: string;
|
||||
}) {
|
||||
const shown = names.slice(0, 2).join(", ");
|
||||
const inner = (
|
||||
<>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
<span className="flex-shrink-0 text-fg-primary">{label}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-muted">{shown}</span>
|
||||
{names.length > 2 && (
|
||||
<span className="flex-shrink-0 text-xs text-fg-muted">+{names.length - 2}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const title = `${label}: ${names.join(", ")}`;
|
||||
return to ? (
|
||||
<Link to={to} title={title} className={`${rowClass} hover:bg-surface-3`}>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" title={title} onClick={onClick} className={rowClass}>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const stem = (p: string) => displayName(p).replace(/\.[A-Za-z0-9]+$/, "");
|
||||
|
||||
export function ChatOutline({
|
||||
outline: o,
|
||||
onClose,
|
||||
onOpenFile,
|
||||
onOpenList,
|
||||
}: {
|
||||
outline: SessionOutline;
|
||||
onClose: () => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
onOpenList: (list: OutlineList) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
|
||||
const close = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title={t("chat.outline.hide")}
|
||||
aria-label={t("chat.outline.hide")}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="chat-outline"
|
||||
aria-label={t("chat.outline.panel")}
|
||||
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
|
||||
>
|
||||
{o.progress.length > 0 && (
|
||||
<Section title={t("chat.outline.progress")} action={close}>
|
||||
{o.progress.map((task) => {
|
||||
const done = task.status === "completed";
|
||||
return (
|
||||
<div key={task.id} className={rowClass} title={task.subject}>
|
||||
{done ? (
|
||||
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse border-accent" : "border-border"}`}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${done ? "text-fg-muted line-through" : task.status === "in_progress" ? "text-fg-primary" : ""}`}
|
||||
>
|
||||
{task.subject}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
<Section
|
||||
title={t("chat.outline.outputs")}
|
||||
count={o.outputs.length}
|
||||
action={o.progress.length ? undefined : close}
|
||||
grow
|
||||
>
|
||||
{o.outputs.length ? (
|
||||
o.outputs.map((p) => {
|
||||
const Icon = fileIcon(p);
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
title={p}
|
||||
aria-label={`${extOf(p)}: ${stem(p)}`}
|
||||
onClick={() => onOpenFile(p)}
|
||||
className={rowClass}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate">{stem(p)}</span>
|
||||
<span className="flex-shrink-0 text-[11px] text-fg-muted">{extOf(p)}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-xs text-fg-muted">{t("chat.outline.empty")}</p>
|
||||
)}
|
||||
</Section>
|
||||
{used > 0 && (
|
||||
<Section title={t("chat.outline.used")}>
|
||||
{o.uploads.length > 0 && (
|
||||
<UsedRow
|
||||
icon={Paperclip}
|
||||
label={t("chat.outline.uploads")}
|
||||
names={o.uploads.map(displayName)}
|
||||
onClick={() => onOpenList("uploads")}
|
||||
/>
|
||||
)}
|
||||
{o.files.length > 0 && (
|
||||
<UsedRow
|
||||
icon={FileSearch}
|
||||
label={t("chat.outline.files")}
|
||||
names={o.files.map(displayName)}
|
||||
onClick={() => onOpenList("files")}
|
||||
/>
|
||||
)}
|
||||
{o.memory.length > 0 && (
|
||||
<UsedRow
|
||||
icon={History}
|
||||
label={t("chat.outline.memory")}
|
||||
names={o.memory.map(stem)}
|
||||
onClick={() => onOpenList("memory")}
|
||||
/>
|
||||
)}
|
||||
{o.skills.length > 0 && (
|
||||
<UsedRow
|
||||
icon={BookOpen}
|
||||
label={t("chat.skills")}
|
||||
names={o.skills}
|
||||
to="/settings/agents/claude-code?tab=skills"
|
||||
/>
|
||||
)}
|
||||
{o.mcp.length > 0 && (
|
||||
<UsedRow
|
||||
icon={Plug}
|
||||
label={t("chat.outline.connectors")}
|
||||
names={o.mcp}
|
||||
to="/settings/agents/claude-code?tab=mcp"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,9 @@
|
||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
|
||||
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
|
||||
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||||
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||||
* Appearance, `lib/chatUi.ts`) is only what a session shows first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -28,7 +29,7 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useChatUi } from "../../lib/chatUi";
|
||||
import { useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
@@ -50,6 +51,8 @@ export interface LaneConsolePaneProps {
|
||||
onRunExit?: () => void;
|
||||
/** The terminal takes the keyboard when it appears (default). */
|
||||
autoFocus?: boolean;
|
||||
/** The only pane shown: its Chat view gets the right panel. */
|
||||
solo?: boolean;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||
@@ -68,6 +71,7 @@ export function LaneConsolePane({
|
||||
onNewSession,
|
||||
onRunExit,
|
||||
autoFocus,
|
||||
solo = false,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||
@@ -78,11 +82,11 @@ export function LaneConsolePane({
|
||||
// The tool of the run on screen — a session's own, which need not be the
|
||||
// workspace's; the workspace's with no run.
|
||||
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
|
||||
const chat = useChatUi();
|
||||
// A run learns its session id after it starts (Codex): the page's poll has it.
|
||||
const chatSession = handle
|
||||
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
|
||||
: null;
|
||||
const chat = useSessionChat(chatSession);
|
||||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -192,10 +196,12 @@ export function LaneConsolePane({
|
||||
{chatable && chat && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
|
||||
<PaneChat
|
||||
runId={handle.id}
|
||||
run={handle}
|
||||
sessionId={chatSession}
|
||||
tool={handle.tool}
|
||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||
laneId={laneId}
|
||||
laneCwd={currentLane?.cwd || ""}
|
||||
outline={solo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,83 +5,319 @@
|
||||
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
|
||||
* attached underneath — a message is sent through that terminal
|
||||
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
|
||||
* nothing new reaches the server. A permission prompt or a menu still needs the
|
||||
* terminal; the hint under the box says so.
|
||||
* nothing new reaches the server. The box carries Claude Desktop's controls:
|
||||
* the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's
|
||||
* context files, recent uploads —, skills, plugins' skills), and for a
|
||||
* Claude pane the Model/Effort chip — a change is typed into the pane as
|
||||
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
||||
* Shift+Tab changes in a running session, so the server steps it and reads
|
||||
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
||||
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
||||
* Shown alone (`outline`), it also has Claude Desktop's activity panel
|
||||
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
|
||||
* file button with the output count until clicked (remembered per browser);
|
||||
* a file clicked there opens its preview in the panel's place
|
||||
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
|
||||
* the workspace's folder —, a group
|
||||
* of files a list with a preview (`FileListModal`). A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
|
||||
* conversation to its end and keeps it there (`follow`), so the message and
|
||||
* the step running after it come into sight on their own. While the session's
|
||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { submitToRun } from "../../lib/panePaste";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { ChatAddMenu } from "./ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
|
||||
import { Select } from "../Select";
|
||||
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
["default", "Default"],
|
||||
["acceptEdits", "AcceptEdits"],
|
||||
["plan", "Plan"],
|
||||
["auto", "Auto"],
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
|
||||
|
||||
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||
const { t } = useTranslation(["run", "lanes"]);
|
||||
const [mode, setMode] = useState<PermissionMode>(initial ?? "default");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const read = useCallback(
|
||||
() =>
|
||||
api.run
|
||||
.permissionMode(runId)
|
||||
.then((r) => isSettable(r.mode) && setMode(r.mode))
|
||||
.catch(() => {}),
|
||||
[runId]
|
||||
);
|
||||
useEffect(() => {
|
||||
void read();
|
||||
}, [read]);
|
||||
// Bypass is in a session's cycle only when it was started with it.
|
||||
const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v);
|
||||
return (
|
||||
<div data-testid="pane-chat-mode">
|
||||
<Select<PermissionMode>
|
||||
variant="ghost"
|
||||
label={t("run:fields.permissionMode")}
|
||||
value={mode}
|
||||
disabled={busy}
|
||||
onChange={(v) => {
|
||||
setBusy(true);
|
||||
api.run
|
||||
.setPermissionMode(runId, v)
|
||||
.then((r) => setMode(r.mode))
|
||||
.catch((err: unknown) => {
|
||||
toast.error(
|
||||
t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" })
|
||||
);
|
||||
void read();
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
options={offered.map(([value, key]) => ({
|
||||
value,
|
||||
label: t(`lanes:home.permission${key}`),
|
||||
hint: t(`run:fields.permission${key}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneChat({
|
||||
runId,
|
||||
run,
|
||||
sessionId,
|
||||
tool,
|
||||
cwd,
|
||||
laneId = null,
|
||||
laneCwd = "",
|
||||
outline = false,
|
||||
}: {
|
||||
runId: string;
|
||||
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||
sessionId: string;
|
||||
tool: string;
|
||||
cwd: string;
|
||||
/** The workspace and its folder: a file under `cwd` is previewed through it. */
|
||||
laneId?: number | null;
|
||||
laneCwd?: string;
|
||||
/** The pane is shown alone: room for the right panel. */
|
||||
outline?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
// Collapsed until asked for, like Claude Desktop's activity panel.
|
||||
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
|
||||
const openOutline = (open: boolean) => {
|
||||
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
|
||||
setOutlineOpen(open);
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [list, setList] = useState<OutlineList | null>(null);
|
||||
const activity = useSessionOutline(sessionId);
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
// The main agent is "working" from a prompt's submit to its Stop hook.
|
||||
const [working, setWorking] = useState(false);
|
||||
const readWorking = useCallback(
|
||||
() =>
|
||||
api.agents
|
||||
.list({ session_id: sessionId, status: "working" })
|
||||
// With session_id the server answers every agent of the session, its
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
|
||||
.catch(() => {}),
|
||||
[sessionId]
|
||||
);
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
useLiveRefresh(readWorking, {
|
||||
throttleMs: 500,
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working ? 5000 : undefined,
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||
});
|
||||
|
||||
// A control is a command typed into the pane, like the user would.
|
||||
const command = (line: string) => {
|
||||
const ok = submitToRun(runId, line);
|
||||
if (!ok) toast.error(t("chat.notSent"));
|
||||
return ok;
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (!text || uploading) return;
|
||||
if (!submitToRun(runId, text)) {
|
||||
toast.error(t("chat.notSent"));
|
||||
return;
|
||||
}
|
||||
if (!command(text)) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={cwd}
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
live
|
||||
follow={follow}
|
||||
onOpenFile={(path) => {
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview, when it has room for one.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
if (outline) setPreview(path);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
||||
{outline && !outlineOpen && !preview && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
data-testid="chat-outline-show"
|
||||
onClick={() => openOutline(true)}
|
||||
title={t("chat.outline.show")}
|
||||
aria-label={t("chat.outline.show")}
|
||||
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{activity.outputs.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
pickFiles={pickFiles}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={claude}
|
||||
fileCwd={cwd}
|
||||
lane={runId}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<ChatAddMenu
|
||||
cwd={cwd}
|
||||
laneId={laneId}
|
||||
skills={claude}
|
||||
onUpload={() => pickFiles.current?.()}
|
||||
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{claude && (
|
||||
<ModelMenu
|
||||
value={model}
|
||||
onChange={(v) => {
|
||||
if (command(`/model ${v || "default"}`)) setModel(v);
|
||||
}}
|
||||
tool={run.tool}
|
||||
effort={effort}
|
||||
onEffortChange={(e) => {
|
||||
if (command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{working && !text ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-stop"
|
||||
onClick={() => {
|
||||
// An interrupted turn fires no Stop hook: clear it here.
|
||||
if (interruptRun(runId)) setWorking(false);
|
||||
else toast.error(t("chat.notStopped"));
|
||||
}}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{outline &&
|
||||
(preview ? (
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when there is room, over it when not.
|
||||
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{list && (
|
||||
<FileListModal
|
||||
title={t(`chat.outline.${list}`)}
|
||||
paths={activity[list]}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
GripVertical,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
@@ -191,9 +193,37 @@ function PaneMenu({
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** A pane's split-right / split-down menus: move a hidden run beside
|
||||
* `slot`, or start a new terminal or AI tool there. In a pane header when the view is
|
||||
* split, beside Tools in the tab bar while one pane is shown. */
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
|
||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
|
||||
const sessionId = run.sessionId;
|
||||
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-chat-toggle-${run.slot ?? 0}`}
|
||||
aria-pressed={chat}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => setSessionChat(sessionId, !chat)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{chat ? (
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A pane's chat/terminal toggle and split-right / split-down menus: move a
|
||||
* hidden run beside `slot`, or start a new terminal or AI tool there. In a
|
||||
* pane header when the view is split, beside Tools in the tab bar while one
|
||||
* pane is shown. */
|
||||
export function SplitMenus({
|
||||
slot,
|
||||
shown,
|
||||
@@ -222,6 +252,7 @@ export function SplitMenus({
|
||||
const full = shown.length >= MAX_PANES;
|
||||
return (
|
||||
<>
|
||||
<ChatToggle run={runs.find((x) => (x.slot ?? 0) === slot)} />
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<PaneMenu
|
||||
key={edge}
|
||||
@@ -446,6 +477,8 @@ export function PaneGrid({
|
||||
key={slot}
|
||||
data-testid={`pane-${slot}`}
|
||||
data-focused={r && focused ? "true" : undefined}
|
||||
// A menu opened in this pane stays inside it (`menuRoom`).
|
||||
data-menu-bounds
|
||||
className={r ? "absolute flex flex-col" : "hidden"}
|
||||
style={r ? pct(r) : undefined}
|
||||
onPointerDownCapture={() => r && focus(slot)}
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
||||
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
|
||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||
* remembered for the + menu's Recent uploads), and a Files panel row
|
||||
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||
@@ -19,7 +23,17 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
@@ -42,9 +56,16 @@ import {
|
||||
} from "../../lib/api";
|
||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||
import type { LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { Select, menuRoom } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
droppedAttachment,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -158,11 +179,16 @@ interface PromptEditorProps {
|
||||
* sends them with the text through `promptWithImages`. */
|
||||
images: PromptImage[];
|
||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||
/** Borderless, for a composer box that draws its own frame. */
|
||||
/** Borderless, for a composer box that draws its own frame — and grows
|
||||
* with its text, from `rows` up to 40% of the screen, then scrolls. */
|
||||
bare?: boolean;
|
||||
/** An image upload started (true) or settled (false): the caller holds its
|
||||
* send button meanwhile, or the prompt would go out without the image. */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** The workspace (id, or a pane's run id) whose panes read the files. */
|
||||
lane?: number | string;
|
||||
/** Set to a function that opens a file picker (a composer's + menu). */
|
||||
pickFiles?: MutableRefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
/** An attached file: its path on the server's machine, and a local preview
|
||||
@@ -175,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
|
||||
export const PASTE_AS_FILE_CHARS = 5000;
|
||||
|
||||
export function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
@@ -187,9 +216,19 @@ export function PromptEditor({
|
||||
onImagesChange,
|
||||
bare,
|
||||
onBusyChange,
|
||||
lane,
|
||||
pickFiles,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// A composer grows with its text: reset to its rows, then take what the
|
||||
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||
useLayoutEffect(() => {
|
||||
const ta = taRef.current;
|
||||
if (!bare || !ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [bare, value]);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
@@ -321,11 +360,11 @@ export function PromptEditor({
|
||||
const busyRef = useRef(onBusyChange);
|
||||
busyRef.current = onBusyChange;
|
||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||
const attachImages = (list: FileList | null | undefined) => {
|
||||
const attachImages = (list: FileList | File[] | null | undefined) => {
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
@@ -333,6 +372,7 @@ export function PromptEditor({
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
})
|
||||
.catch(() => setUpload("failed"));
|
||||
@@ -342,12 +382,11 @@ export function PromptEditor({
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!viewing) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [viewing]);
|
||||
const closeViewer = useCallback(() => setViewing(null), []);
|
||||
// The composer's own "Upload or add files" opens this picker; what it picks
|
||||
// takes the same path as a paste or a drop.
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
@@ -451,6 +490,19 @@ export function PromptEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pickFiles && (
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="prompt-file-input"
|
||||
onChange={(e) => {
|
||||
attachImages(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
@@ -464,8 +516,28 @@ export function PromptEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// A Files panel row is not a file: say a drop is welcome.
|
||||
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const att = droppedAttachment(e.dataTransfer);
|
||||
if (att) {
|
||||
e.preventDefault();
|
||||
onImagesChange((prev) => addAttachments(prev, [att]));
|
||||
return;
|
||||
}
|
||||
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
@@ -473,7 +545,7 @@ export function PromptEditor({
|
||||
spellCheck={false}
|
||||
className={
|
||||
bare
|
||||
? "block w-full resize-none bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
}
|
||||
/>
|
||||
@@ -542,20 +614,7 @@ export function PromptEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewing && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={viewing.name}
|
||||
onClick={() => setViewing(null)}
|
||||
className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||
>
|
||||
<img
|
||||
src={viewing.url}
|
||||
alt={viewing.name}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -670,7 +729,10 @@ export function CwdAutocomplete({
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
|
||||
<div
|
||||
data-cwd-suggestions
|
||||
className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
|
||||
) : (
|
||||
@@ -853,9 +915,11 @@ export function ModelMenu({
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Below the chip unless the viewport has more room above it.
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom);
|
||||
// Below the chip unless its pane (or the viewport) has more room above it.
|
||||
if (ref.current) {
|
||||
const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
|
||||
setUp(below < 360 && above > below);
|
||||
}
|
||||
const away = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
|
||||
@@ -192,7 +192,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
@@ -236,6 +236,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
|
||||
fontWeight: 500,
|
||||
fontWeightBold: 700,
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
});
|
||||
@@ -556,7 +559,10 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// On screen (a console hidden with CSS measures 0×0) AND attached:
|
||||
// `onData` drops input while the socket is connecting or reconnecting,
|
||||
// so a caller waits or says the paste failed instead.
|
||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN
|
||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||
(data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file ChatAddMenu.test.tsx
|
||||
* @description The `+` menu's existing-resource pickers: Project files (a
|
||||
* search with folders, attached as `@path`), the workspace's Context files
|
||||
* (files only, none missing) and this browser's Recent uploads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ChatAddMenu } from "../ChatAddMenu";
|
||||
import { api } from "../../../lib/api";
|
||||
import { rememberUploads } from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||
lanes: {
|
||||
sources: vi.fn().mockResolvedValue({
|
||||
sources: [
|
||||
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||
{ id: 2, kind: "file", path: "/data/ctx/1/gone.md", name: "gone.md", missing: true },
|
||||
{ id: 3, kind: "folder", path: "/srv/docs", name: "docs", missing: false },
|
||||
],
|
||||
}),
|
||||
},
|
||||
ccConfig: {
|
||||
skills: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderMenu(laneId: number | null = 7) {
|
||||
const onAttach = vi.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatAddMenu
|
||||
cwd="/repo"
|
||||
laneId={laneId}
|
||||
skills={false}
|
||||
onUpload={() => {}}
|
||||
onInsert={() => {}}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
return onAttach;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ChatAddMenu — existing resources", () => {
|
||||
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-project"));
|
||||
fireEvent.click(await screen.findByTestId("chat-add-project-src/"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/repo", "", true);
|
||||
expect(onAttach).toHaveBeenCalledWith([{ path: "@src/", name: "src/" }]);
|
||||
});
|
||||
|
||||
it("offers the workspace's context files that are still there, files only", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-context"));
|
||||
fireEvent.click(await screen.findByTestId("chat-add-context-brief.md"));
|
||||
expect(api.lanes.sources).toHaveBeenCalledWith(7);
|
||||
expect(screen.queryByTestId("chat-add-context-gone.md")).toBeNull();
|
||||
expect(onAttach).toHaveBeenCalledWith([{ path: "@/data/ctx/1/brief.md", name: "brief.md" }]);
|
||||
});
|
||||
|
||||
it("has no Context files without a workspace", () => {
|
||||
renderMenu(null);
|
||||
expect(screen.queryByTestId("chat-add-context")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists recent uploads newest first and attaches one as it was sent", () => {
|
||||
rememberUploads([{ path: "/tmp/d/old.pdf", name: "old.pdf", image: false }]);
|
||||
rememberUploads([{ path: "/tmp/d/0123456789abcdef.png", name: "shot.png", image: true }]);
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-recent"));
|
||||
const rows = screen.getAllByTestId(/^chat-add-recent-/);
|
||||
expect(rows.map((r) => r.textContent)).toEqual(["shot.png", "old.pdf"]);
|
||||
fireEvent.click(rows[0]!);
|
||||
expect(onAttach).toHaveBeenCalledWith([
|
||||
{
|
||||
path: "/tmp/d/0123456789abcdef.png",
|
||||
name: "shot.png",
|
||||
url: "/api/run/drop/0123456789abcdef.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file ChatFilePreview.test.tsx
|
||||
* @description Where a Chat pane's file preview reads from: a file under the
|
||||
* pane's folder through the lane's git file read, a memory file under
|
||||
* `~/.claude` through the config pages' read, anything else by path only; and
|
||||
* the list dialog previewing the file picked in it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
gitFile: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/a.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["export const a = 1;"],
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
ccConfig: {
|
||||
file: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
|
||||
|
||||
describe("FilePreview", () => {
|
||||
it("reads a workspace file through the lane", async () => {
|
||||
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
});
|
||||
|
||||
it("reads a memory file through the config read", async () => {
|
||||
const mem = "/h/.claude/projects/-w/memory/launch.md";
|
||||
render(<FilePreview path={mem} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Launch notes")).toBeTruthy();
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", () => {
|
||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileListModal", () => {
|
||||
it("previews the file picked in the list", async () => {
|
||||
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
|
||||
expect(displayName(drop)).toBe("minutes.xlsx");
|
||||
render(
|
||||
<FileListModal
|
||||
title="Uploads"
|
||||
paths={[drop, "/w/src/a.ts"]}
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file ChatOutline.test.tsx
|
||||
* @description The Chat pane's activity panel renders a session's outline the
|
||||
* way Claude Desktop does: progress with done/current items, outputs by name
|
||||
* with an EXT tag (a click opens that file), and "Used in this session" rows —
|
||||
* uploads, files read and memory open their list, skills link to the config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ChatOutline } from "../ChatOutline";
|
||||
|
||||
const outline = {
|
||||
progress: [
|
||||
{ id: "1", subject: "Parse plan", status: "completed" },
|
||||
{ id: "2", subject: "Build report", status: "in_progress" },
|
||||
],
|
||||
outputs: ["/w/out/Progress report.xlsx"],
|
||||
files: ["/w/src/a.ts", "/w/src/b.ts", "/w/src/c.ts"],
|
||||
uploads: ["/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx"],
|
||||
memory: ["/h/.claude/projects/-w/memory/release.md"],
|
||||
skills: ["xlsx"],
|
||||
mcp: ["vercel_vercel"],
|
||||
};
|
||||
|
||||
describe("ChatOutline", () => {
|
||||
it("shows progress, outputs and what the session used", () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenFile = vi.fn();
|
||||
const onOpenList = vi.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={outline}
|
||||
onClose={onClose}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenList={onOpenList}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText("Parse plan").className).toContain("line-through");
|
||||
expect(screen.getByText("Build report").className).not.toContain("line-through");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
||||
|
||||
// An upload is named without the drop dir's random prefix.
|
||||
fireEvent.click(screen.getByTitle("Uploads: minutes.xlsx"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("uploads");
|
||||
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
|
||||
fireEvent.click(screen.getByTitle("Memory: release"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("memory");
|
||||
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
|
||||
"/settings/agents/claude-code?tab=skills"
|
||||
);
|
||||
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides progress and the used section when there is none", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={{
|
||||
...outline,
|
||||
progress: [],
|
||||
files: [],
|
||||
uploads: [],
|
||||
memory: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
}}
|
||||
onClose={() => {}}
|
||||
onOpenFile={() => {}}
|
||||
onOpenList={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByText("Progress")).toBeNull();
|
||||
expect(screen.queryByText("Used in this session")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Hide side panel" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { __resetChatUi } from "../../../lib/chatUi";
|
||||
import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
@@ -33,7 +33,10 @@ vi.mock("../PaneChat", () => ({
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
|
||||
me: {
|
||||
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
|
||||
savePrefs: vi.fn(),
|
||||
},
|
||||
lanes: {
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
@@ -171,7 +174,41 @@ describe("LaneConsolePane", () => {
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
|
||||
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
|
||||
__resetChatUi();
|
||||
});
|
||||
|
||||
it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => {
|
||||
vi.mocked(api.me.prefs).mockResolvedValueOnce({
|
||||
prefs: { "agenthub.chatUi": "1" },
|
||||
onboardedAt: null,
|
||||
});
|
||||
__resetChatUi();
|
||||
const live = {
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: "s-1",
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
tool: "claude" as const,
|
||||
};
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||
expect(await screen.findByTestId("pane-chat")).toBeInTheDocument();
|
||||
// Seen once, the session keeps chat: turning the default off leaves it alone.
|
||||
await waitFor(() =>
|
||||
expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true })
|
||||
);
|
||||
vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null });
|
||||
await act(() => saveChatUi(false));
|
||||
expect(screen.getByTestId("pane-chat")).toBeInTheDocument();
|
||||
// Its own toggle switches it.
|
||||
act(() => setSessionChat("s-1", false));
|
||||
expect(screen.queryByTestId("pane-chat")).toBeNull();
|
||||
__resetChatUi();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @file PaneChat.test.tsx
|
||||
* @description The Chat view's controls: the `+` menu (Upload, Skills, Plugins
|
||||
* → a plugin's skills, each inserted as `/<name>`), the Model/Effort chip typed
|
||||
* into the pane as `/model` / `/effort`, and the permission mode read off the
|
||||
* pane and stepped by the server — none of them for a Codex pane; and the
|
||||
* activity panel, collapsed into a file button with the output count, whose
|
||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||
* Stop in Send's place while the main agent works, pressing Esc in the pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneChat } from "../PaneChat";
|
||||
import { api } from "../../../lib/api";
|
||||
import { interruptRun, submitToRun } from "../../../lib/panePaste";
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
|
||||
<div data-testid="conversation">
|
||||
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
agents: { list: r({ agents: [] }) },
|
||||
aiTools: {
|
||||
models: r({
|
||||
flag: true,
|
||||
models: [
|
||||
{ id: "opus", label: "Opus" },
|
||||
{ id: "sonnet", label: "Sonnet" },
|
||||
],
|
||||
defaultModel: null,
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
files: r({ items: [] }),
|
||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||
permissionMode: r({ mode: "acceptEdits" }),
|
||||
setPermissionMode: r({ mode: "plan" }),
|
||||
},
|
||||
sessions: {
|
||||
outline: r({
|
||||
progress: [],
|
||||
outputs: ["/w/notes.md", "/w/report.xlsx"],
|
||||
files: [],
|
||||
uploads: [],
|
||||
memory: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
}),
|
||||
},
|
||||
lanes: {
|
||||
gitFile: r({
|
||||
available: true,
|
||||
path: "notes.md",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["# Notes", "", "hello"],
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({ items: [] }),
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "morning", frontmatter: { description: "Plan the day" } }],
|
||||
}),
|
||||
plugins: r({
|
||||
plugins: [
|
||||
{
|
||||
name: "doox",
|
||||
enabled: true,
|
||||
contributes: {
|
||||
items: {
|
||||
skills: [{ name: "plan-consolidation", description: "Merge plans" }],
|
||||
commands: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off",
|
||||
enabled: false,
|
||||
contributes: { items: { skills: [{ name: "x" }], commands: [] } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const RUN = {
|
||||
id: "agenthub-lane-1",
|
||||
tool: "claude" as "claude" | "codex",
|
||||
model: "opus",
|
||||
effort: "high" as const,
|
||||
permissionMode: "auto" as const,
|
||||
};
|
||||
|
||||
function renderChat(run: Partial<typeof RUN> = {}) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={{ ...RUN, ...run }} sessionId="s1" cwd="/w" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.mocked(submitToRun).mockClear());
|
||||
|
||||
describe("PaneChat — the + menu", () => {
|
||||
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
||||
renderChat();
|
||||
const box = screen.getByPlaceholderText("Write a message…") as HTMLTextAreaElement;
|
||||
fireEvent.change(box, { target: { value: "today" } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
fireEvent.click(screen.getByTestId("chat-add-skills"));
|
||||
fireEvent.click(await screen.findByTestId("chat-add-skill-morning"));
|
||||
expect(box.value).toBe("/morning today");
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
fireEvent.click(screen.getByTestId("chat-add-plugins"));
|
||||
expect(screen.queryByTestId("chat-add-plugin-off")).toBeNull(); // disabled plugin
|
||||
fireEvent.click(await screen.findByTestId("chat-add-plugin-doox"));
|
||||
fireEvent.click(screen.getByTestId("chat-add-skill-doox:plan-consolidation"));
|
||||
expect(box.value).toBe("/doox:plan-consolidation /morning today");
|
||||
});
|
||||
|
||||
it("Upload opens the prompt box's file picker", () => {
|
||||
renderChat();
|
||||
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
|
||||
const clicked = vi.spyOn(input, "click");
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
fireEvent.click(screen.getByTestId("chat-add-upload"));
|
||||
expect(clicked).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — Stop", () => {
|
||||
it("replaces Send while the main agent works and the box is empty, and presses Esc", async () => {
|
||||
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||
agents: [{ type: "main", status: "working" }],
|
||||
} as never);
|
||||
renderChat();
|
||||
fireEvent.click(await screen.findByTestId("pane-chat-stop"));
|
||||
expect(interruptRun).toHaveBeenCalledWith("agenthub-lane-1");
|
||||
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||
expect(screen.getByTestId("pane-chat-send")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops Stop once the turn ended even when no live update says so", async () => {
|
||||
// A missed agent_updated (another dashboard on the same database got the
|
||||
// hook, or the socket dropped): the poll while working catches it.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||
agents: [{ type: "main", status: "working" }],
|
||||
} as never);
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Send for a session at rest", async () => {
|
||||
// With session_id the server answers EVERY agent of the session (its
|
||||
// status filter does not apply): the main agent is there, waiting.
|
||||
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||
agents: [{ type: "main", status: "waiting" }],
|
||||
} as never);
|
||||
renderChat();
|
||||
await waitFor(() => expect(api.agents.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — model, effort and permission mode", () => {
|
||||
it("types /model into the pane", async () => {
|
||||
renderChat();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
|
||||
});
|
||||
|
||||
it("shows the mode the pane shows, and asks the server to step it", async () => {
|
||||
renderChat();
|
||||
const chip = byId("pane-chat-mode");
|
||||
await waitFor(() => expect(chip()).toHaveTextContent("Accept edits"));
|
||||
fireEvent.click(chip().querySelector("button")!);
|
||||
fireEvent.click(await screen.findByRole("option", { name: /Plan/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.run.setPermissionMode).toHaveBeenCalledWith("agenthub-lane-1", "plan")
|
||||
);
|
||||
await waitFor(() => expect(chip()).toHaveTextContent("Plan"));
|
||||
});
|
||||
|
||||
it("a Codex pane gets the + menu's Upload only — no skills, model or mode", () => {
|
||||
renderChat({ tool: "codex" });
|
||||
expect(screen.queryByTestId("pane-chat-mode")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Opus/ })).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
expect(screen.getByTestId("chat-add-upload")).toBeTruthy();
|
||||
expect(screen.queryByTestId("chat-add-skills")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const byId = (id: string) => () => screen.getByTestId(id);
|
||||
|
||||
describe("PaneChat — the activity panel", () => {
|
||||
it("opens from the file button, previews a file, and closes back to the panel", async () => {
|
||||
localStorage.removeItem("agenthub.chat.outlineOpen");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
const show = screen.getByTestId("chat-outline-show");
|
||||
await waitFor(() => expect(show.textContent).toBe("2"));
|
||||
|
||||
fireEvent.click(show);
|
||||
fireEvent.click(screen.getByRole("button", { name: "MD: notes" }));
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
await screen.findByTestId("markdown-preview");
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(7, "notes.md");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — an edited file", () => {
|
||||
it("opens in the Changes panel when it is in the pane's folder, else in its own preview", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const listen = (e: Event) => seen.push((e as CustomEvent).detail);
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("edited-in"));
|
||||
expect(seen).toEqual([{ laneId: 7, path: "src/a.ts" }]);
|
||||
expect(screen.queryByTestId("chat-file-preview")).toBeNull();
|
||||
// Outside the folder the Changes panel cannot show it: the chat's preview does.
|
||||
fireEvent.click(screen.getByTestId("edited-out"));
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(await screen.findByTestId("chat-file-preview")).toBeInTheDocument();
|
||||
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@
|
||||
* @description The run-form pieces that outlived the pane's run form:
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
||||
* pasted image shown as a thumbnail and sent as its saved path (and
|
||||
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
||||
* chip, a composer
|
||||
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
* same list as one chip: aliases up front, pinned versions under More models,
|
||||
@@ -13,17 +16,19 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
ModelMenu,
|
||||
ModelPicker,
|
||||
PASTE_AS_FILE_CHARS,
|
||||
PromptEditor,
|
||||
promptWithImages,
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -158,6 +163,34 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptEditor — a composer grows with its text", () => {
|
||||
it("takes the height its text needs, capped by max-h, and only when bare", () => {
|
||||
const tall = vi
|
||||
.spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
|
||||
.mockReturnValue(180);
|
||||
const props = {
|
||||
placeholder: "Ask anything…",
|
||||
slash: false,
|
||||
fileCwd: "/Users/tester",
|
||||
images: [],
|
||||
onImagesChange: () => {},
|
||||
onChange: () => {},
|
||||
onSubmit: () => {},
|
||||
};
|
||||
const view = render(<PromptEditor {...props} value={"a\nb\nc\nd\ne"} bare />);
|
||||
expect(box().style.height).toBe("180px");
|
||||
expect(box().className).toContain("max-h-[40vh]");
|
||||
tall.mockReturnValue(60);
|
||||
view.rerender(<PromptEditor {...props} value="a" bare />);
|
||||
expect(box().style.height).toBe("60px");
|
||||
view.unmount();
|
||||
// The framed editor keeps its fixed rows.
|
||||
render(<PromptEditor {...props} value={"a\nb\nc"} />);
|
||||
expect(box().style.height).toBe("");
|
||||
tall.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptEditor — newlines and images", () => {
|
||||
it("Alt+Enter inserts a newline and does not send", () => {
|
||||
const spies = renderEditor("ab");
|
||||
@@ -186,6 +219,7 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
lane={7}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("see", images)}</output>
|
||||
</>
|
||||
@@ -195,12 +229,14 @@ describe("PromptEditor — newlines and images", () => {
|
||||
const img = new File(["x"], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
||||
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(img);
|
||||
// Saved where workspace 7's panes can read it.
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(img, 7);
|
||||
// The path is not typed into the prompt; it goes with it on send.
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||
|
||||
fireEvent.click(thumb);
|
||||
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
|
||||
// On the page, not in the composer's layer: nothing stacked over a pane covers it.
|
||||
expect(screen.getByRole("dialog", { name: "shot.png" }).parentElement).toBe(document.body);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
@@ -210,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
||||
});
|
||||
|
||||
it("a composer's Upload opens a picker whose files attach like a paste", async () => {
|
||||
const pick: { current: (() => void) | null } = { current: null };
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
pickFiles={pick}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
|
||||
const clicked = vi.spyOn(input, "click");
|
||||
pick.current?.();
|
||||
expect(clicked).toHaveBeenCalled();
|
||||
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(input, { target: { files: [doc] } });
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined); // no workspace named
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
||||
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||
function Harness() {
|
||||
@@ -235,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
||||
// Remembered for the + menu's Recent uploads.
|
||||
expect(recentUploads()).toContainEqual({
|
||||
path: "/tmp/agenthub-drops-1/b-spec.pdf",
|
||||
name: "spec.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const data: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (data[k] = v),
|
||||
getData: (k: string) => data[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(data);
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
// What a Files panel row's onDragStart puts on the drag.
|
||||
dragProjectPath({ dataTransfer } as unknown as React.DragEvent, "src/lib/");
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
expect(screen.getByText("lib/")).toBeTruthy();
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
||||
});
|
||||
|
||||
it("leaves a text paste to the textarea", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||
fireEvent.paste(box(), {
|
||||
clipboardData: { files: [], getData: () => "a short note" },
|
||||
});
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches a very long text paste as a text file, like a pasted file (#68)", async () => {
|
||||
renderEditor("");
|
||||
const long = "x".repeat(PASTE_AS_FILE_CHARS + 1);
|
||||
fireEvent.paste(box(), { clipboardData: { files: [], getData: () => long } });
|
||||
await waitFor(() => expect(api.run.dropFile).toHaveBeenCalledTimes(1));
|
||||
const file = vi.mocked(api.run.dropFile).mock.calls[0]![0] as File;
|
||||
expect(file.name).toBe("pasted-text.txt");
|
||||
expect(file.type).toBe("text/plain");
|
||||
expect(file.size).toBe(long.length); // the whole paste, nothing cut
|
||||
expect(box()).toHaveValue(""); // not typed into the box
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelPicker", () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropFileMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -481,7 +481,8 @@ describe("TerminalView", () => {
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||
);
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||
// The pane's run id: saved where that pane can read it.
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
||||
* **Run setup again** reopens the first-run wizard (`components/onboarding`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,8 +20,10 @@ import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { ChatUiSettings } from "./ChatUiSettings";
|
||||
import { Select } from "../Select";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { resetOnboarding } from "../../lib/userPrefs";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
@@ -29,7 +32,7 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
"apple-dark": "Apple Dark",
|
||||
"apple-light": "Apple Light",
|
||||
@@ -44,7 +47,7 @@ const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "system", Icon: Monitor },
|
||||
];
|
||||
|
||||
function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
export function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -96,6 +99,8 @@ export function AppearanceSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ChatUiSettings />
|
||||
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
@@ -126,8 +131,9 @@ export function AppearanceSettings() {
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("terminalFont.title")}
|
||||
// A dropdown: five font names overflow a segmented row.
|
||||
<Select<TerminalFont>
|
||||
label={t("terminalFont.title")}
|
||||
value={termFont}
|
||||
onChange={pickTermFont}
|
||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||
@@ -148,8 +154,6 @@ export function AppearanceSettings() {
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<ChatUiSettings />
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
@@ -221,6 +225,26 @@ export function AppearanceSettings() {
|
||||
</div>
|
||||
<PetSettings />
|
||||
</section>
|
||||
|
||||
<SettingsSection
|
||||
id="setup"
|
||||
title={t("onboarding:rerun.label")}
|
||||
description={t("onboarding:rerun.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("onboarding:rerun.label")}
|
||||
control={
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOnboarding}
|
||||
data-testid="rerun-setup"
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("onboarding:rerun.button")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,16 +88,17 @@ describe("AppearanceSettings", () => {
|
||||
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
|
||||
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
|
||||
const trigger = screen.getByRole("button", { name: /Terminal font/ });
|
||||
expect(trigger).toHaveTextContent("JetBrains Mono");
|
||||
const pick = async (name: string) => {
|
||||
await user.click(screen.getByRole("button", { name: /Terminal font/ }));
|
||||
await user.click(screen.getByRole("option", { name }));
|
||||
};
|
||||
await pick("Maple Mono (rounded)");
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
|
||||
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
|
||||
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
|
||||
await user.click(within(group).getByRole("radio", { name: "System font" }));
|
||||
await pick("System font");
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* frames missed while disconnected are never replayed. The copy is dropped
|
||||
* when the last component unmounts, so a fresh mount always reads fresh rows.
|
||||
* `useWorkLanes()` is the same list narrowed to the workspaces this user may
|
||||
* work in (server mode: an analyst's own), for the Workspace page and tree.
|
||||
* work in (server mode: an analyst's own), for the Workspace page; the sidebar
|
||||
* tree lists them all, other users' read only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -97,10 +98,10 @@ export function useLanes() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspaces this user WORKS in — the Workspace page and its sidebar tree.
|
||||
* The workspaces this user WORKS in — the Workspace page.
|
||||
* Server mode: an analyst gets every workspace from the API (to read) but may
|
||||
* change only their own, so only those are listed here; other users' work is
|
||||
* read on the Sessions, Analytics and Users pages.
|
||||
* read in the sidebar tree (read only), and on Sessions, Analytics and Users.
|
||||
*/
|
||||
export function useWorkLanes() {
|
||||
const all = useLanes();
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
* themselves are CSS-variable blocks in `index.css`. State is never cached in
|
||||
* the module — every instance reads storage + `matchMedia` through
|
||||
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
|
||||
* another tab's change arrives through the `storage` event.
|
||||
* another tab's change arrives through the `storage` event. Every change is also
|
||||
* saved to the account (`/api/me/prefs`, same keys); a sign-in loads it back
|
||||
* through `lib/userPrefs.ts` (`hydrate`), while the login page wears the OS's
|
||||
* light/dark (`applySystemTheme`) because nobody has chosen yet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
export type ThemeMode = Theme | "system";
|
||||
@@ -19,7 +23,7 @@ export type ThemeMode = Theme | "system";
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||
light: ["default", "apple-light", "canvas", "orca-light"],
|
||||
light: ["default", "canvas", "apple-light", "orca-light"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
const MODE_KEY = "theme";
|
||||
@@ -35,13 +39,20 @@ function read(key: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string): void {
|
||||
function store(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
/** A user's own choice: this browser, every reader, and the account. */
|
||||
function write(key: string, value: string): void {
|
||||
store(key, value);
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
// This browser keeps it even when the save fails (offline, signed out).
|
||||
api.me.savePrefs({ [key]: value }).catch(() => {});
|
||||
}
|
||||
|
||||
function systemQuery(): MediaQueryList | undefined {
|
||||
@@ -86,6 +97,17 @@ function apply(resolved: Theme, name: string): void {
|
||||
else root.dataset.theme = name;
|
||||
}
|
||||
|
||||
/** Paint what this browser stores (index.html's job at page load). */
|
||||
export function applyStoredTheme(): void {
|
||||
const [, resolved, dark, light] = getSnapshot().split("|") as [ThemeMode, Theme, string, string];
|
||||
apply(resolved, resolved === "dark" ? dark : light);
|
||||
}
|
||||
|
||||
/** The login page: the OS's light/dark in the stock palette. */
|
||||
export function applySystemTheme(): void {
|
||||
apply(systemQuery()?.matches === false ? "light" : "dark", "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/write the dashboard's appearance.
|
||||
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
|
||||
|
||||
@@ -115,6 +115,7 @@ export const i18nReady = i18n
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
"onboarding",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -186,14 +186,18 @@
|
||||
"keep": "Keep editing"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Sign in to AgentHub",
|
||||
"title": "Welcome back",
|
||||
"subtitle": "Sign in to AgentHub with your account",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"submit": "Sign in",
|
||||
"submitting": "Signing in…",
|
||||
"failed": "Wrong username or password",
|
||||
"tooMany": "Too many failed attempts — try again in a few minutes",
|
||||
"logout": "Sign out",
|
||||
"noAccount": "No account yet? Ask your administrator to create one.",
|
||||
"signedInAs": "Signed in as {{name}} ({{role}})"
|
||||
},
|
||||
"insights": {
|
||||
|
||||
@@ -91,10 +91,8 @@
|
||||
"changes.range.work": "Uncommitted",
|
||||
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
|
||||
"changes.refresh": "Refresh",
|
||||
"changes.resize": "Resize the changes panel (next: {{size}})",
|
||||
"changes.size.compact": "Narrow",
|
||||
"changes.size.half": "Medium",
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.restore": "Restore size",
|
||||
"changes.fileCount_one": "{{count}} file",
|
||||
"changes.fileCount_other": "{{count}} files",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
@@ -102,6 +100,7 @@
|
||||
"changes.title": "Changes",
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
"changes.unavailable": "This workspace's directory is not a readable git repository.",
|
||||
"changes.unavailableFolder": "This folder is not a readable git repository.",
|
||||
"changes.unstage": "Unstage this file",
|
||||
"changes.changesView": "Changes",
|
||||
"changes.wholeFile": "Whole file",
|
||||
@@ -134,8 +133,6 @@
|
||||
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
|
||||
"destructive.reset.title": "Reset managed worktree?",
|
||||
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
|
||||
"empty.body": "A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
|
||||
"empty.title": "No workspaces yet",
|
||||
"features.archived": "archived",
|
||||
"features.live": "Live",
|
||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the workspace keeps running; this is a read-only snapshot.",
|
||||
@@ -298,6 +295,7 @@
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.folder": "Folder to show changes for",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
"replay.hint": "Sending resumes the session in a free pane.",
|
||||
"replay.send": "Send",
|
||||
@@ -316,6 +314,10 @@
|
||||
"tree.search": "Search workspaces and sessions",
|
||||
"tree.toolFilter": "Filter by tool",
|
||||
"tree.allTools": "All tools",
|
||||
"tree.ownerYou": "{{name}} (you)",
|
||||
"tree.noOwner": "No owner",
|
||||
"tree.ownerToggle": "Show or hide {{name}}'s workspaces",
|
||||
"tree.readOnly": "{{name}}'s workspace — read only. A session opens its analysis page.",
|
||||
"tree.newSession": "New session",
|
||||
"tree.noSessions": "No sessions yet",
|
||||
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
||||
@@ -389,6 +391,8 @@
|
||||
"panes.split.bottom": "Split down",
|
||||
"panes.full": "A tab holds at most 4 panes",
|
||||
"panes.newTerminal": "New terminal",
|
||||
"panes.showChat": "Show as chat",
|
||||
"panes.showTerminal": "Show the terminal",
|
||||
"panes.close": "Move to its own tab (the session keeps running)",
|
||||
"panes.resize": "Resize panes",
|
||||
"tabs.home": "Workspace home",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"brand": "AgentHub",
|
||||
"progress": "{{n}} of {{total}}",
|
||||
"skip": "Skip setup",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"finish": "Add your first workspace",
|
||||
"agent": {
|
||||
"eyebrow": "Welcome to AgentHub",
|
||||
"title": "Pick your default agent",
|
||||
"body": "AgentHub works with every CLI agent. Choose the one you'll reach for most. Switch any time.",
|
||||
"detected": "Detected on this machine · {{count}}",
|
||||
"none": "No AI tool is installed yet. An admin installs them in Settings → AI Tools; you can finish setup and pick one later.",
|
||||
"more": "Show {{count}} more agents",
|
||||
"less": "Show fewer",
|
||||
"notInstalled": "not installed",
|
||||
"permission": "Start new sessions in",
|
||||
"permissionHint": "Bypass permissions is never a default: it stays a red switch you turn on for one session.",
|
||||
"modes": {
|
||||
"auto": "Auto",
|
||||
"acceptEdits": "Accept edits",
|
||||
"default": "Ask first",
|
||||
"plan": "Plan"
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"title": "Make it feel like home",
|
||||
"body": "Pick the look you want to stare at for hours.",
|
||||
"modes": {
|
||||
"system": "System",
|
||||
"dark": "Dark",
|
||||
"light": "Light"
|
||||
},
|
||||
"modeHints": {
|
||||
"system": "Match OS",
|
||||
"dark": "Easy on the eyes",
|
||||
"light": "Bright & crisp"
|
||||
},
|
||||
"darkThemes": "Dark theme",
|
||||
"lightThemes": "Light theme",
|
||||
"language": "Language",
|
||||
"font": "Terminal font",
|
||||
"more": "More options — themes, fonts, the pet — in Settings → Appearance."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pick a companion",
|
||||
"body": "A small pet sits in the corner and reacts to what your agents do. Hide it any time."
|
||||
},
|
||||
"notify": {
|
||||
"title": "Set up notifications",
|
||||
"body": "AgentHub tells you when agents are done or need you.",
|
||||
"enable": "Desktop notifications",
|
||||
"enableHint": "Your browser asks for permission once.",
|
||||
"blocked": "Notifications are blocked for this site — allow them in the browser's site settings.",
|
||||
"sound": "Notification sound",
|
||||
"preview": "Play",
|
||||
"test": "Send test notification"
|
||||
},
|
||||
"tour": {
|
||||
"progress": "Step {{n}} of {{total}}",
|
||||
"skip": "Skip tour",
|
||||
"next": "Next",
|
||||
"done": "Done",
|
||||
"goWorkspaces": "Go to Workspaces",
|
||||
"add": {
|
||||
"title": "Create your first workspace",
|
||||
"body": "A workspace is one project folder where your agents work. Click + beside Workspaces in the sidebar to add one."
|
||||
},
|
||||
"folder": {
|
||||
"title": "Choose the project folder",
|
||||
"body": "Type a path or Browse to the folder of your project. A git repository gets a Changes panel too."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create it",
|
||||
"body": "Pick the AI it starts on, then click Add workspace."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Start your first session",
|
||||
"body": "Tell the agent what to do and press Enter. AI, model and permission mode sit right here."
|
||||
},
|
||||
"session": {
|
||||
"title": "Your session is live",
|
||||
"body": "Watch it work and type into it like a terminal. Changes and Files are in the panel on the right; every session of this workspace is listed in the sidebar."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set",
|
||||
"body": "Run setup again or change any of it in Settings."
|
||||
}
|
||||
},
|
||||
"rerun": {
|
||||
"label": "First-run setup",
|
||||
"description": "Go through the welcome steps and the tour again.",
|
||||
"button": "Run setup again"
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,38 @@
|
||||
"chat": {
|
||||
"placeholder": "Write a message…",
|
||||
"send": "Send",
|
||||
"hint": "Enter sends · permission prompts and menus need the terminal (Settings → Appearance → Chat UI)",
|
||||
"notSent": "The terminal is not connected — the message was not sent."
|
||||
"notSent": "The terminal is not connected — the message was not sent.",
|
||||
"stop": "Stop",
|
||||
"notStopped": "The terminal is not connected — nothing was stopped.",
|
||||
"add": "Add files, skills…",
|
||||
"upload": "Upload or add files",
|
||||
"projectFiles": "Project files",
|
||||
"contextFiles": "Context files",
|
||||
"recentUploads": "Recent uploads",
|
||||
"searchFiles": "Search files and folders…",
|
||||
"skills": "Skills",
|
||||
"plugins": "Plugins",
|
||||
"manageSkills": "Manage skills",
|
||||
"managePlugins": "Manage plugins",
|
||||
"none": "Nothing here yet",
|
||||
"fromPlugin": "{{name}} plugin",
|
||||
"modeFailed": "Could not change the permission mode: {{message}}",
|
||||
"outline": {
|
||||
"progress": "Progress",
|
||||
"outputs": "Outputs",
|
||||
"connectors": "Connectors",
|
||||
"empty": "Nothing yet",
|
||||
"hide": "Hide side panel",
|
||||
"show": "Activity panel",
|
||||
"copyPath": "Copy path: {{path}}",
|
||||
"panel": "Session activity panel",
|
||||
"used": "Used in this session",
|
||||
"uploads": "Uploads",
|
||||
"files": "Files read",
|
||||
"memory": "Memory",
|
||||
"close": "Close",
|
||||
"noPreview": "No preview for this file here",
|
||||
"truncated": "Showing the first part of this file"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,10 @@
|
||||
"agents_other": "ran {{count}} agents",
|
||||
"tools_one": "used a tool",
|
||||
"tools_other": "used {{count}} tools"
|
||||
}
|
||||
},
|
||||
"edited_one": "Edited {{count}} file",
|
||||
"edited_other": "Edited {{count}} files",
|
||||
"working": "Thinking…"
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"bubbles": "Speech bubbles",
|
||||
"bubblesDesc": "Let the pet comment on your sessions in a bubble",
|
||||
"sizeDesc": "{{min}}–{{max}} px; the preview is capped at 120 px",
|
||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved in this browser.",
|
||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
|
||||
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
||||
"panel": {
|
||||
"label": "Sagi companion",
|
||||
@@ -189,7 +189,7 @@
|
||||
"alert": "Alert",
|
||||
"forType": "Sound for this type"
|
||||
},
|
||||
"scopeNote": "These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
|
||||
"scopeNote": "These notification settings are saved to your account. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
|
||||
"insecureOrigin": "This page is served from {{origin}}, which is not a secure origin. Browsers only show notifications on HTTPS or localhost — open the dashboard through one of those to get them.",
|
||||
"unsupported": "This browser does not support notifications.",
|
||||
"deniedHelp": "Notifications are blocked for this site. To allow them again, open the site settings (the icon left of the address bar), set Notifications to Allow, then reload this page.",
|
||||
@@ -309,6 +309,7 @@
|
||||
"label": "Start AgentHub automatically",
|
||||
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"desc_systemd-system": "Run the dashboard as the system service agenthub.service (server mode, one Linux user per account), so it starts with the machine.",
|
||||
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
||||
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
||||
},
|
||||
@@ -662,7 +663,12 @@
|
||||
"test": "Test",
|
||||
"testing": "Testing…",
|
||||
"testOk": "answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "no answer ({{status}}): {{message}}"
|
||||
"testFailed": "no answer ({{status}}): {{message}}",
|
||||
"personal": "Personal",
|
||||
"fieldScope": "Who can use it",
|
||||
"scopeShared": "Everyone (shared)",
|
||||
"scopePersonal": "Only me (personal)",
|
||||
"fieldScopeHint": "A shared profile's key never reaches anyone's browser. A personal one is listed, edited and used by you alone."
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -697,7 +703,7 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only.",
|
||||
"description": "Colour mode and theme. Saved to your account, so every browser you sign in to gets them.",
|
||||
"mode": "Mode",
|
||||
"modes": {
|
||||
"dark": "Dark",
|
||||
@@ -882,7 +888,7 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "The language of the whole dashboard, remembered in this browser.",
|
||||
"description": "The language of the whole dashboard, saved to your account.",
|
||||
"label": "Interface language",
|
||||
"names": {
|
||||
"en": "English",
|
||||
@@ -891,7 +897,7 @@
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved in this browser only.",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -1001,7 +1007,7 @@
|
||||
},
|
||||
"prefsBackup": {
|
||||
"title": "This browser's preferences",
|
||||
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
||||
"description": "Theme, language, notification settings and pet follow your account; the sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
||||
"export": "Export preferences",
|
||||
"import": "Import preferences…",
|
||||
"importTitle": "Import preferences?",
|
||||
@@ -1016,9 +1022,9 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Chat UI",
|
||||
"description": "How a Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||
"label": "Open sessions as a chat",
|
||||
"labelDesc": "Show every Claude or Codex session's conversation like Claude Desktop, with a message box, instead of its terminal. Permission prompts and menus still need the terminal: turn this off to answer them.",
|
||||
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||
"label": "Open new sessions as a chat",
|
||||
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
||||
"saveFailed": "Could not save: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,5 +69,7 @@
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
||||
"auditEmpty": "Nothing recorded yet."
|
||||
"auditEmpty": "Nothing recorded yet.",
|
||||
"activeCount": "{{n}} active",
|
||||
"cost30d": "Cost · 30 days"
|
||||
}
|
||||
|
||||
@@ -186,14 +186,18 @@
|
||||
"keep": "Tiếp tục sửa"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Đăng nhập AgentHub",
|
||||
"title": "Chào mừng trở lại",
|
||||
"subtitle": "Đăng nhập AgentHub bằng tài khoản của bạn",
|
||||
"username": "Tên đăng nhập",
|
||||
"password": "Mật khẩu",
|
||||
"showPassword": "Hiện mật khẩu",
|
||||
"hidePassword": "Ẩn mật khẩu",
|
||||
"submit": "Đăng nhập",
|
||||
"submitting": "Đang đăng nhập…",
|
||||
"failed": "Sai tên đăng nhập hoặc mật khẩu",
|
||||
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
|
||||
"logout": "Đăng xuất",
|
||||
"noAccount": "Chưa có tài khoản? Hãy nhờ quản trị viên tạo cho bạn.",
|
||||
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
|
||||
},
|
||||
"insights": {
|
||||
|
||||
@@ -91,10 +91,8 @@
|
||||
"changes.range.work": "Chưa commit",
|
||||
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
|
||||
"changes.refresh": "Tải lại",
|
||||
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
|
||||
"changes.size.compact": "Hẹp",
|
||||
"changes.size.half": "Vừa",
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.restore": "Thu nhỏ",
|
||||
"changes.fileCount_one": "{{count}} tệp",
|
||||
"changes.fileCount_other": "{{count}} tệp",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
@@ -102,6 +100,7 @@
|
||||
"changes.title": "Thay đổi",
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
|
||||
"changes.unavailableFolder": "Thư mục này không phải repo git đọc được.",
|
||||
"changes.unstage": "Bỏ file này khỏi index",
|
||||
"changes.changesView": "Thay đổi",
|
||||
"changes.wholeFile": "Toàn bộ file",
|
||||
@@ -134,8 +133,6 @@
|
||||
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
|
||||
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
||||
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
|
||||
"empty.body": "Mỗi workspace là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
|
||||
"empty.title": "Chưa có workspace nào",
|
||||
"features.archived": "đã lưu trữ",
|
||||
"features.live": "Phiên bản trực tiếp",
|
||||
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — workspace tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
||||
@@ -298,6 +295,7 @@
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.folder": "Thư mục để xem thay đổi",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||
"replay.send": "Gửi",
|
||||
@@ -316,6 +314,10 @@
|
||||
"tree.search": "Tìm workspace và session",
|
||||
"tree.toolFilter": "Lọc theo tool",
|
||||
"tree.allTools": "Mọi tool",
|
||||
"tree.ownerYou": "{{name}} (bạn)",
|
||||
"tree.noOwner": "Không có chủ",
|
||||
"tree.ownerToggle": "Hiện hoặc ẩn workspace của {{name}}",
|
||||
"tree.readOnly": "Workspace của {{name}} — chỉ xem. Bấm một phiên để mở trang phân tích.",
|
||||
"tree.newSession": "Session mới",
|
||||
"tree.noSessions": "Chưa có session",
|
||||
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
||||
@@ -389,6 +391,8 @@
|
||||
"panes.split.bottom": "Tách xuống dưới",
|
||||
"panes.full": "Một tab chứa tối đa 4 pane",
|
||||
"panes.newTerminal": "Terminal mới",
|
||||
"panes.showChat": "Xem dạng chat",
|
||||
"panes.showTerminal": "Xem dạng terminal",
|
||||
"panes.close": "Tách ra tab riêng (session vẫn chạy)",
|
||||
"panes.resize": "Đổi kích thước pane",
|
||||
"tabs.home": "Trang chủ workspace",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"brand": "AgentHub",
|
||||
"progress": "{{n}} / {{total}}",
|
||||
"skip": "Bỏ qua thiết lập",
|
||||
"back": "Quay lại",
|
||||
"continue": "Tiếp tục",
|
||||
"finish": "Thêm workspace đầu tiên",
|
||||
"agent": {
|
||||
"eyebrow": "Chào mừng đến AgentHub",
|
||||
"title": "Chọn agent mặc định",
|
||||
"body": "AgentHub chạy được mọi CLI agent. Chọn cái bạn dùng nhiều nhất — đổi lúc nào cũng được.",
|
||||
"detected": "Đã có trên máy này · {{count}}",
|
||||
"none": "Chưa có AI tool nào được cài. Admin cài trong Cài đặt → AI Tools; bạn có thể hoàn tất thiết lập và chọn sau.",
|
||||
"more": "Xem thêm {{count}} agent",
|
||||
"less": "Thu gọn",
|
||||
"notInstalled": "chưa cài",
|
||||
"permission": "Session mới bắt đầu ở chế độ",
|
||||
"permissionHint": "Bypass permissions không bao giờ là mặc định: nó vẫn là công tắc đỏ bạn bật cho từng session.",
|
||||
"modes": {
|
||||
"auto": "Tự động",
|
||||
"acceptEdits": "Nhận sửa đổi",
|
||||
"default": "Hỏi trước",
|
||||
"plan": "Lập kế hoạch"
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"title": "Làm cho nó thật quen thuộc",
|
||||
"body": "Chọn giao diện bạn sẽ nhìn hàng giờ liền.",
|
||||
"modes": {
|
||||
"system": "Hệ thống",
|
||||
"dark": "Tối",
|
||||
"light": "Sáng"
|
||||
},
|
||||
"modeHints": {
|
||||
"system": "Theo hệ điều hành",
|
||||
"dark": "Dịu mắt",
|
||||
"light": "Sáng rõ"
|
||||
},
|
||||
"darkThemes": "Theme tối",
|
||||
"lightThemes": "Theme sáng",
|
||||
"language": "Ngôn ngữ",
|
||||
"font": "Font terminal",
|
||||
"more": "Thêm tuỳ chọn — theme, font, thú cưng — trong Cài đặt → Giao diện."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Chọn một người bạn đồng hành",
|
||||
"body": "Một thú cưng nhỏ ngồi ở góc màn hình và phản ứng với việc agent đang làm. Ẩn lúc nào cũng được."
|
||||
},
|
||||
"notify": {
|
||||
"title": "Thiết lập thông báo",
|
||||
"body": "AgentHub báo cho bạn khi agent xong việc hoặc cần bạn.",
|
||||
"enable": "Thông báo trên máy",
|
||||
"enableHint": "Trình duyệt sẽ hỏi quyền một lần.",
|
||||
"blocked": "Trang này đang bị chặn thông báo — cho phép trong cài đặt trang của trình duyệt.",
|
||||
"sound": "Âm thanh thông báo",
|
||||
"preview": "Nghe thử",
|
||||
"test": "Gửi thông báo thử"
|
||||
},
|
||||
"tour": {
|
||||
"progress": "Bước {{n}} / {{total}}",
|
||||
"skip": "Bỏ qua hướng dẫn",
|
||||
"next": "Tiếp",
|
||||
"done": "Xong",
|
||||
"goWorkspaces": "Mở Workspaces",
|
||||
"add": {
|
||||
"title": "Tạo workspace đầu tiên",
|
||||
"body": "Workspace là một thư mục dự án nơi các agent làm việc. Bấm dấu + cạnh mục Không gian làm việc ở sidebar để thêm."
|
||||
},
|
||||
"folder": {
|
||||
"title": "Chọn thư mục dự án",
|
||||
"body": "Gõ đường dẫn hoặc bấm Browse tới thư mục dự án. Repo git sẽ có thêm bảng Changes."
|
||||
},
|
||||
"create": {
|
||||
"title": "Tạo workspace",
|
||||
"body": "Chọn AI sẽ dùng, rồi bấm Thêm workspace."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Bắt đầu session đầu tiên",
|
||||
"body": "Viết việc cần agent làm rồi nhấn Enter. Chọn AI, model và chế độ quyền ngay tại đây."
|
||||
},
|
||||
"session": {
|
||||
"title": "Session đang chạy",
|
||||
"body": "Xem agent làm việc và gõ vào như một terminal. Changes và Files nằm ở bảng bên phải; mọi session của workspace này có trong thanh bên."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Xong rồi!",
|
||||
"body": "Chạy lại thiết lập hoặc đổi bất cứ lựa chọn nào trong Cài đặt."
|
||||
}
|
||||
},
|
||||
"rerun": {
|
||||
"label": "Thiết lập ban đầu",
|
||||
"description": "Đi lại các bước chào mừng và phần hướng dẫn.",
|
||||
"button": "Chạy lại thiết lập"
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,38 @@
|
||||
"chat": {
|
||||
"placeholder": "Viết tin nhắn…",
|
||||
"send": "Gửi",
|
||||
"hint": "Enter để gửi · hỏi quyền và menu cần terminal (Settings → Appearance → Giao diện chat)",
|
||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi."
|
||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||
"stop": "Ngưng",
|
||||
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
||||
"add": "Thêm file, skill…",
|
||||
"upload": "Tải lên hoặc thêm file",
|
||||
"projectFiles": "File trong dự án",
|
||||
"contextFiles": "File ngữ cảnh",
|
||||
"recentUploads": "Đã tải lên gần đây",
|
||||
"searchFiles": "Tìm file và thư mục…",
|
||||
"skills": "Skills",
|
||||
"plugins": "Plugins",
|
||||
"manageSkills": "Quản lý skills",
|
||||
"managePlugins": "Quản lý plugins",
|
||||
"none": "Chưa có gì",
|
||||
"fromPlugin": "Plugin {{name}}",
|
||||
"modeFailed": "Không đổi được permission mode: {{message}}",
|
||||
"outline": {
|
||||
"progress": "Tiến độ",
|
||||
"outputs": "Kết quả",
|
||||
"connectors": "Kết nối",
|
||||
"empty": "Chưa có gì",
|
||||
"hide": "Ẩn thanh bên",
|
||||
"show": "Bảng hoạt động",
|
||||
"copyPath": "Sao chép đường dẫn: {{path}}",
|
||||
"panel": "Bảng hoạt động của phiên",
|
||||
"used": "Đã dùng trong phiên",
|
||||
"uploads": "Tệp tải lên",
|
||||
"files": "Tệp đã đọc",
|
||||
"memory": "Bộ nhớ",
|
||||
"close": "Đóng",
|
||||
"noPreview": "Không xem trước được tệp này ở đây",
|
||||
"truncated": "Chỉ hiện phần đầu của tệp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,10 @@
|
||||
"agents_other": "chạy {{count}} agent",
|
||||
"tools_one": "dùng 1 công cụ",
|
||||
"tools_other": "dùng {{count}} công cụ"
|
||||
}
|
||||
},
|
||||
"edited_one": "Đã sửa {{count}} tệp",
|
||||
"edited_other": "Đã sửa {{count}} tệp",
|
||||
"working": "Đang suy nghĩ…"
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"bubbles": "Bong bóng lời thoại",
|
||||
"bubblesDesc": "Cho pet bình luận về các session của bạn trong bong bóng",
|
||||
"sizeDesc": "{{min}}–{{max}} px; bản xem trước tối đa 120 px",
|
||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu trong trình duyệt này.",
|
||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
|
||||
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
|
||||
"panel": {
|
||||
"label": "Trợ lý Sagi",
|
||||
@@ -189,7 +189,7 @@
|
||||
"alert": "Cảnh báo",
|
||||
"forType": "Âm thanh cho loại này"
|
||||
},
|
||||
"scopeNote": "Các cài đặt thông báo này chỉ lưu trong trình duyệt này. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
|
||||
"scopeNote": "Các cài đặt thông báo này được lưu vào tài khoản của bạn. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
|
||||
"insecureOrigin": "Trang này được phục vụ từ {{origin}}, không phải nguồn bảo mật. Trình duyệt chỉ hiện thông báo trên HTTPS hoặc localhost — hãy mở bảng điều khiển qua một trong hai cách đó.",
|
||||
"unsupported": "Trình duyệt này không hỗ trợ thông báo.",
|
||||
"deniedHelp": "Thông báo đang bị chặn cho trang này. Để cho phép lại, mở cài đặt trang (biểu tượng bên trái thanh địa chỉ), đặt Thông báo thành Cho phép, rồi tải lại trang.",
|
||||
@@ -309,6 +309,7 @@
|
||||
"label": "Tự động khởi động AgentHub",
|
||||
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"desc_systemd-system": "Chạy dashboard bằng system service agenthub.service (chế độ server, mỗi tài khoản một Linux user) để nó tự bật cùng máy.",
|
||||
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
|
||||
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
|
||||
},
|
||||
@@ -662,7 +663,12 @@
|
||||
"test": "Kiểm tra",
|
||||
"testing": "Đang kiểm tra…",
|
||||
"testOk": "đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "không trả lời ({{status}}): {{message}}"
|
||||
"testFailed": "không trả lời ({{status}}): {{message}}",
|
||||
"personal": "Riêng",
|
||||
"fieldScope": "Ai được dùng",
|
||||
"scopeShared": "Mọi người (dùng chung)",
|
||||
"scopePersonal": "Chỉ tôi (riêng)",
|
||||
"fieldScopeHint": "Key của profile dùng chung không bao giờ tới trình duyệt của ai. Profile riêng chỉ mình bạn thấy, sửa và dùng."
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -697,7 +703,7 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
|
||||
"description": "Chế độ màu và theme. Lưu vào tài khoản, nên mọi trình duyệt bạn đăng nhập đều dùng chúng.",
|
||||
"mode": "Chế độ",
|
||||
"modes": {
|
||||
"dark": "Tối",
|
||||
@@ -882,7 +888,7 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được ghi nhớ trong trình duyệt này.",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.",
|
||||
"label": "Ngôn ngữ giao diện",
|
||||
"names": {
|
||||
"en": "English",
|
||||
@@ -891,7 +897,7 @@
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Chỉ lưu trong trình duyệt này.",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -1001,7 +1007,7 @@
|
||||
},
|
||||
"prefsBackup": {
|
||||
"title": "Tùy chọn của trình duyệt này",
|
||||
"description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
|
||||
"description": "Giao diện, ngôn ngữ, cài đặt thông báo và thú cưng theo tài khoản của bạn; thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
|
||||
"export": "Xuất tùy chọn",
|
||||
"import": "Nhập tùy chọn…",
|
||||
"importTitle": "Nhập tùy chọn?",
|
||||
@@ -1016,9 +1022,9 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Giao diện chat",
|
||||
"description": "Cách một session Claude hoặc Codex mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"label": "Mở session dạng chat",
|
||||
"labelDesc": "Hiện hội thoại của mọi session Claude hoặc Codex như Claude Desktop, kèm ô nhập tin, thay cho terminal. Hỏi quyền và menu vẫn cần terminal: tắt mục này để trả lời chúng.",
|
||||
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"label": "Mở session mới dạng chat",
|
||||
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
|
||||
"saveFailed": "Không lưu được: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,5 +69,7 @@
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
|
||||
"auditEmpty": "Chưa ghi nhận gì."
|
||||
"auditEmpty": "Chưa ghi nhận gì.",
|
||||
"activeCount": "{{n}} đang hoạt động",
|
||||
"cost30d": "Chi phí · 30 ngày"
|
||||
}
|
||||
|
||||
@@ -351,9 +351,14 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--border)) rgb(var(--surface-2));
|
||||
/* Claude's: a faint thumb on no track. */
|
||||
scrollbar-color: rgb(var(--fg-muted) / 0.35) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
@@ -362,7 +367,7 @@
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: rgb(var(--surface-2));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* @description Pins where a pasted diff comment lands: the workspace's pane
|
||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
||||
* is not on screen (a hidden console behind a full-width Changes panel).
|
||||
* is not on screen (a hidden console behind a full-width Changes panel); and
|
||||
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||
|
||||
describe("panePaste", () => {
|
||||
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||||
@@ -48,4 +49,20 @@ describe("panePaste", () => {
|
||||
expect(got).toEqual(["y"]);
|
||||
off();
|
||||
});
|
||||
|
||||
it("interruptRun presses Esc in a visible pane only", () => {
|
||||
const keys: string[] = [];
|
||||
let shown = false;
|
||||
const off = registerPane(
|
||||
"agenthub-lane-6",
|
||||
() => {},
|
||||
() => shown,
|
||||
(d) => keys.push(d)
|
||||
);
|
||||
expect(interruptRun("agenthub-lane-6")).toBe(false);
|
||||
shown = true;
|
||||
expect(interruptRun("agenthub-lane-6")).toBe(true);
|
||||
expect(keys).toEqual(["\x1b"]);
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
+77
-24
@@ -555,6 +555,8 @@ export type AuditEntry = {
|
||||
/** Who is using the dashboard: local mode has no login at all. */
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
|
||||
|
||||
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||
* components mounting, a WS event fanning out to many listeners) share one
|
||||
@@ -642,22 +644,30 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
*/
|
||||
/**
|
||||
* What a workspace git call works on: the workspace (its id) or, with
|
||||
* `session`, that session's own worktree (`claude --worktree`) — the server
|
||||
* resolves the tree from the session row (`?session=`), never from a path.
|
||||
* `session`, that session's own worktree (`claude --worktree`), or, with
|
||||
* `source`, one of the workspace's extra folders — the server resolves the
|
||||
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
|
||||
* never from a path.
|
||||
*/
|
||||
export type GitTarget = number | { lane: number; session?: string };
|
||||
export type GitTarget = number | { lane: number; session?: string; source?: number };
|
||||
|
||||
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */
|
||||
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
||||
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
const params = new URLSearchParams(q);
|
||||
const lane = typeof target === "number" ? target : target.lane;
|
||||
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
||||
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
||||
const qs = params.toString();
|
||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/** The target for `laneId`, in `session`'s worktree when one is given. */
|
||||
export function gitTarget(laneId: number, session?: string | null): GitTarget {
|
||||
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
||||
export function gitTarget(
|
||||
laneId: number,
|
||||
session?: string | null,
|
||||
source?: number | null
|
||||
): GitTarget {
|
||||
if (source) return { lane: laneId, source };
|
||||
return session ? { lane: laneId, session } : laneId;
|
||||
}
|
||||
|
||||
@@ -675,13 +685,15 @@ export const api = {
|
||||
/** POST /api/auth/logout — ends this login. */
|
||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
|
||||
// ───────────────────────── The account's own preferences (routes/me.js) ─────────────────────────
|
||||
me: {
|
||||
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
|
||||
prefs: () => request<MePrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
|
||||
/** GET /api/me/prefs — this account's preferences and first-run flag. */
|
||||
prefs: () => request<UserPrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -907,6 +919,9 @@ export const api = {
|
||||
* @returns {@link SessionStats} for that one session.
|
||||
*/
|
||||
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
|
||||
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
|
||||
* and the skills / MCP servers it used (a Chat pane's side panel). */
|
||||
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
|
||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||
* workspace, deletes nothing). */
|
||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||
@@ -2433,10 +2448,12 @@ export const api = {
|
||||
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
|
||||
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
|
||||
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
||||
/** GET /api/run/files - path-completion suggestions under `cwd`. */
|
||||
files: (cwd: string, q?: string) => {
|
||||
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
|
||||
* adds the folders too, as `dir/`. */
|
||||
files: (cwd: string, q?: string, dirs = false) => {
|
||||
const qs = new URLSearchParams({ cwd });
|
||||
if (q) qs.set("q", q);
|
||||
if (dirs) qs.set("dirs", "1");
|
||||
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
||||
},
|
||||
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
||||
@@ -2448,18 +2465,37 @@ export const api = {
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
|
||||
* name so the agent sees what it is. */
|
||||
dropFile: (file: File) =>
|
||||
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
|
||||
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
|
||||
* run id) saves it where THAT workspace's panes can read it. */
|
||||
dropFile: (file: File, lane?: number | string | null) => {
|
||||
const laneId =
|
||||
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
|
||||
const qs = new URLSearchParams({ name: file.name });
|
||||
if (laneId != null) qs.set("laneId", String(laneId));
|
||||
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
});
|
||||
},
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
/** GET /api/run/:id - one run's current handle. */
|
||||
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
|
||||
/** GET /api/run/:id/permission-mode - the mode a live claude pane shows now
|
||||
* (null while a menu hides it). */
|
||||
permissionMode: (id: string) =>
|
||||
request<{ mode: PermissionMode | "dontAsk" | null }>(
|
||||
`/run/${encodeURIComponent(id)}/permission-mode`
|
||||
),
|
||||
/** POST /api/run/:id/permission-mode - step the pane's mode (Shift+Tab, each
|
||||
* step read back) to `mode`; 409 when it cannot be reached. */
|
||||
setPermissionMode: (id: string, mode: PermissionMode) =>
|
||||
request<{ mode: PermissionMode }>(`/run/${encodeURIComponent(id)}/permission-mode`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
/** DELETE /api/run/:id - kill the tmux session. */
|
||||
kill: (id: string) =>
|
||||
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
@@ -3600,6 +3636,10 @@ export interface AiProxy {
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** "shared": the host's (an admin's, in server mode), usable by everyone;
|
||||
* "personal": the signed-in user's own, named `u<id>_<slug>` — server mode
|
||||
* only, and nobody else lists it. */
|
||||
scope?: "shared" | "personal";
|
||||
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
@@ -3651,6 +3691,9 @@ export interface AiProxyInput {
|
||||
models?: Partial<AiProxyModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
/** Server mode, on create: an admin's profile is shared unless "personal";
|
||||
* a member's is always personal. Ignored in local mode. */
|
||||
scope?: "shared" | "personal";
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
@@ -4277,6 +4320,22 @@ export interface CodexMarketplaceActionArgs {
|
||||
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
|
||||
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
||||
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
|
||||
/** `GET /api/sessions/:id/outline`. `status` is the agent's own word
|
||||
* (`pending` / `in_progress` / `completed`). */
|
||||
export interface SessionOutline {
|
||||
progress: { id: string; subject: string; status: string }[];
|
||||
/** Files it wrote or edited, newest first. */
|
||||
outputs: string[];
|
||||
/** Files it only read, newest first. */
|
||||
files: string[];
|
||||
/** Files attached to its prompts (the composer's drop dir), newest first. */
|
||||
uploads: string[];
|
||||
/** Claude Code memory files it read or wrote, newest first. */
|
||||
memory: string[];
|
||||
skills: string[];
|
||||
mcp: string[];
|
||||
}
|
||||
|
||||
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
|
||||
* crashed, claude exited and closed the pane). Computed fresh from tmux
|
||||
@@ -4312,12 +4371,6 @@ export interface RunStartArgs {
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
* name (`agenthub-lane-<laneId>`). */
|
||||
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
|
||||
export interface MePrefs {
|
||||
prefs: Record<string, string>;
|
||||
onboardedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RunHandle {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
@@ -4528,7 +4581,7 @@ export interface RemoteSourceInput {
|
||||
* write, which it never touches. */
|
||||
export interface AutostartStatus {
|
||||
available: boolean;
|
||||
kind: "systemd" | "launchd";
|
||||
kind: "systemd" | "systemd-system" | "launchd";
|
||||
enabled: boolean;
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @file brand.ts
|
||||
* @description SmartGift logo URLs shared by the sidebar and the login page.
|
||||
* Remote URLs: the logo is owned by the brand, not vendored here — a machine
|
||||
* with no network shows the alt text instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
export const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
+64
-15
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
|
||||
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
|
||||
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
|
||||
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
|
||||
* any browser and differs between users of one dashboard. Read once per page
|
||||
* load and shared through `useChatUi`; `saveChatUi` updates every reader at
|
||||
* once and puts the old value back if the save fails.
|
||||
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
|
||||
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
|
||||
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
|
||||
* chat/terminal button), kept in this browser's localStorage
|
||||
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
|
||||
* setting is only the DEFAULT: kept by the server per account
|
||||
* (`GET/PUT /api/me/prefs`, key `agenthub.chatUi` = "1"; local mode is one
|
||||
* account), it is written onto a session the first time that session is
|
||||
* shown, so changing it later leaves every session already seen alone. Read
|
||||
* once per page load and shared through `useChatUi`; `saveChatUi` updates
|
||||
* every reader at once and puts the old value back if the save fails.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -14,21 +18,44 @@ import { api } from "./api";
|
||||
|
||||
export const CHAT_UI_KEY = "agenthub.chatUi";
|
||||
|
||||
export const VIEWS_KEY = "agenthub.chatUi.sessions";
|
||||
const MAX_VIEWS = 200;
|
||||
|
||||
let chatUi = false;
|
||||
let loaded = false;
|
||||
let loading: Promise<void> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const subscribe = (fn: () => void) => {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
};
|
||||
|
||||
const emit = (next: boolean) => {
|
||||
chatUi = next;
|
||||
listeners.forEach((fn) => fn());
|
||||
};
|
||||
|
||||
function readViews(): Record<string, boolean> {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}");
|
||||
return v && typeof v === "object" ? (v as Record<string, boolean>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
let views = readViews();
|
||||
|
||||
function load() {
|
||||
// A failed read (logged out, offline) must never take a page down with it.
|
||||
loading ??= Promise.resolve()
|
||||
.then(() => api.me.prefs())
|
||||
.then(
|
||||
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"),
|
||||
(r) => {
|
||||
loaded = true;
|
||||
emit(r.prefs[CHAT_UI_KEY] === "1");
|
||||
},
|
||||
() => {
|
||||
loading = null; // the next reader asks again
|
||||
}
|
||||
@@ -37,13 +64,32 @@ function load() {
|
||||
|
||||
export function useChatUi(): boolean {
|
||||
useEffect(load, []);
|
||||
return useSyncExternalStore(
|
||||
(fn) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
() => chatUi
|
||||
);
|
||||
return useSyncExternalStore(subscribe, () => chatUi);
|
||||
}
|
||||
|
||||
/** Shows `sessionId` as a chat (`true`) or its terminal, for this browser. */
|
||||
export function setSessionChat(sessionId: string, on: boolean) {
|
||||
const { [sessionId]: _old, ...rest } = views;
|
||||
// Newest last, so the cap drops the sessions touched longest ago.
|
||||
views = Object.fromEntries(Object.entries({ ...rest, [sessionId]: on }).slice(-MAX_VIEWS));
|
||||
try {
|
||||
localStorage.setItem(VIEWS_KEY, JSON.stringify(views));
|
||||
} catch {
|
||||
// Storage full or blocked: the choice still holds for this page load.
|
||||
}
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
/** The session's own view; a session seen for the first time takes the
|
||||
* account's default (once it is read) and keeps it. */
|
||||
export function useSessionChat(sessionId: string | null | undefined): boolean {
|
||||
const fallback = useChatUi();
|
||||
const own = useSyncExternalStore(subscribe, () => (sessionId ? views[sessionId] : undefined));
|
||||
const ready = useSyncExternalStore(subscribe, () => loaded);
|
||||
useEffect(() => {
|
||||
if (sessionId && own === undefined && ready) setSessionChat(sessionId, chatUi);
|
||||
}, [sessionId, own, ready]);
|
||||
return own ?? fallback;
|
||||
}
|
||||
|
||||
export async function saveChatUi(on: boolean): Promise<void> {
|
||||
@@ -61,5 +107,8 @@ export async function saveChatUi(on: boolean): Promise<void> {
|
||||
/** Tests: forget what was read. */
|
||||
export function __resetChatUi() {
|
||||
chatUi = false;
|
||||
loaded = false;
|
||||
loading = null;
|
||||
views = {};
|
||||
localStorage.removeItem(VIEWS_KEY);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @file openInChanges.ts — asks the Workspace page to open a file in its
|
||||
* Changes panel: a chat's "Edited N files" card names absolute paths deep in
|
||||
* a pane, the panel lives on the page, so the request travels as a window
|
||||
* event (like the sidebar's `LIVE_RUNS_EVENT`). The page opens the panel and
|
||||
* that file there, if the panel shows the same workspace; else nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const OPEN_IN_CHANGES_EVENT = "agenthub:open-in-changes";
|
||||
|
||||
/** A file to open: its workspace, and its path relative to the panel's folder. */
|
||||
export type OpenInChanges = { laneId: number; path: string };
|
||||
|
||||
/** Opens `path` (absolute) when it lies under `cwd`, the pane's folder — the
|
||||
* folder the Changes panel reads for it. False when it does not. */
|
||||
export function openInChanges(laneId: number, cwd: string, path: string): boolean {
|
||||
const base = cwd.replace(/\/+$/, "");
|
||||
if (!base || !path.startsWith(`${base}/`)) return false;
|
||||
const detail: OpenInChanges = { laneId, path: path.slice(base.length + 1) };
|
||||
window.dispatchEvent(new CustomEvent(OPEN_IN_CHANGES_EVENT, { detail }));
|
||||
return true;
|
||||
}
|
||||
@@ -9,25 +9,28 @@
|
||||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||||
* unseen); the caller says where it went, or that it went nowhere. The one
|
||||
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
||||
* what the user typed there, so it pastes AND presses Enter.
|
||||
* what the user typed there, so it pastes AND presses Enter — and its Stop
|
||||
* button presses Esc through `interruptRun`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
interface Pane {
|
||||
paste: (text: string, submit?: boolean) => void;
|
||||
visible: () => boolean;
|
||||
key?: (data: string) => void;
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now); returns its
|
||||
* unregister. */
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||||
* raw input, as a key press); returns its unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true
|
||||
visible: () => boolean = () => true,
|
||||
key?: (data: string) => void
|
||||
): () => void {
|
||||
const entry = { paste, visible };
|
||||
const entry = { paste, visible, key };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
@@ -63,6 +66,15 @@ export function submitToRun(runId: string, text: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn
|
||||
* they are running. False when the pane is not mounted or not attached. */
|
||||
export function interruptRun(runId: string): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible() || !p.key) return false;
|
||||
p.key("\x1b");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||||
* last when it is — and returns its run id; null when none is on screen. */
|
||||
export function pasteToLane(laneId: number, text: string): string | null {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
|
||||
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
|
||||
* expanded to their sessions (by lane id), the tool the list is filtered to,
|
||||
* and — Settings → Worktrees —
|
||||
* which owner groups an admin/analyst folded, and — Settings → Worktrees —
|
||||
* whether a workspace home's Add folder suggests the repo's untracked
|
||||
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
|
||||
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
|
||||
@@ -20,6 +20,8 @@ export interface ProjectTreePrefs {
|
||||
expanded: Record<string, boolean>;
|
||||
/** Show only this tool's workspaces; null = every tool. */
|
||||
tool: string | null;
|
||||
/** Server mode, admin/analyst: owner key (user id, or "none") → its group is folded. */
|
||||
collapsedOwners: Record<string, boolean>;
|
||||
showUntracked: boolean;
|
||||
hideSources: string[];
|
||||
}
|
||||
@@ -27,6 +29,7 @@ export interface ProjectTreePrefs {
|
||||
const DEFAULTS: ProjectTreePrefs = {
|
||||
expanded: {},
|
||||
tool: null,
|
||||
collapsedOwners: {},
|
||||
showUntracked: true,
|
||||
hideSources: [],
|
||||
};
|
||||
@@ -50,6 +53,10 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
|
||||
value = {
|
||||
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
|
||||
tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null,
|
||||
collapsedOwners:
|
||||
parsed.collapsedOwners && typeof parsed.collapsedOwners === "object"
|
||||
? parsed.collapsedOwners
|
||||
: {},
|
||||
showUntracked: parsed.showUntracked !== false,
|
||||
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file promptAttach.ts
|
||||
* @description What a prompt box can attach besides a fresh upload: a project
|
||||
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
||||
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
|
||||
* last day (`recentUploads`, kept in this browser). An attachment is a path
|
||||
* sent with the prompt, so nothing here touches the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { DragEvent } from "react";
|
||||
|
||||
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
|
||||
export interface Attachment {
|
||||
path: string;
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const ATTACH_MIME = "application/x-agenthub-attach";
|
||||
|
||||
/** A project path (relative to the box's folder) as Claude Code's `@` mention. */
|
||||
export function projectAttachment(rel: string): Attachment {
|
||||
const name = rel.replace(/\/$/, "").split("/").pop() || rel;
|
||||
return { path: `@${rel}`, name: rel.endsWith("/") ? `${name}/` : name };
|
||||
}
|
||||
|
||||
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
|
||||
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
|
||||
export function dragProjectPath(e: DragEvent, rel: string) {
|
||||
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
|
||||
e.dataTransfer.setData("text/plain", rel);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
|
||||
/** The attachment a drop carries, if it is one of ours. */
|
||||
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
|
||||
try {
|
||||
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
|
||||
const a = v as Attachment | null;
|
||||
return a && typeof a.path === "string" && typeof a.name === "string"
|
||||
? { path: a.path, name: a.name }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `prev` plus whichever of `items` it does not hold yet. */
|
||||
export function addAttachments<T extends Attachment>(prev: T[], items: T[]): T[] {
|
||||
const fresh = items.filter(
|
||||
(a, i) =>
|
||||
!prev.some((p) => p.path === a.path) && items.findIndex((b) => b.path === a.path) === i
|
||||
);
|
||||
return fresh.length ? [...prev, ...fresh] : prev;
|
||||
}
|
||||
|
||||
const RECENT_KEY = "agenthub.prompt.recentUploads";
|
||||
const RECENT_MAX = 20;
|
||||
// The server prunes an upload a day after it was written (`saveDrop`).
|
||||
// ponytail: a client-side guess of that TTL, a few minutes short; a listing
|
||||
// route instead if uploads must outlive it or follow the user to another browser.
|
||||
const RECENT_TTL_MS = 23.9 * 60 * 60_000;
|
||||
|
||||
interface Recent extends Attachment {
|
||||
at: number;
|
||||
}
|
||||
|
||||
function readRecent(): Recent[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
|
||||
const cutoff = Date.now() - RECENT_TTL_MS;
|
||||
return (Array.isArray(v) ? (v as Recent[]) : []).filter(
|
||||
(r) => typeof r?.path === "string" && typeof r.name === "string" && r.at > cutoff
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function recentUploads(): Attachment[] {
|
||||
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
|
||||
}
|
||||
|
||||
/** Remember uploads just saved, newest first; an image keeps a thumbnail
|
||||
* through `GET /api/run/drop/:name` (its local blob URL dies with the page). */
|
||||
export function rememberUploads(items: { path: string; name: string; image: boolean }[]) {
|
||||
const at = Date.now();
|
||||
const added: Recent[] = items.map(({ path, name, image }) => ({
|
||||
path,
|
||||
name,
|
||||
at,
|
||||
...(image ? { url: `/api/run/drop/${encodeURIComponent(path.split("/").pop() || "")}` } : {}),
|
||||
}));
|
||||
const kept = readRecent().filter((r) => !added.some((a) => a.path === r.path));
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify([...added, ...kept].slice(0, RECENT_MAX)));
|
||||
} catch {
|
||||
/* storage full or blocked: the list is a convenience */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file sessionDefaults.ts
|
||||
* @description The account's own starting choices for new work, picked in the
|
||||
* first-run setup (docs/plans/onboarding) and synced per account like every
|
||||
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
|
||||
* the permission mode a new Claude session starts in. Bypass is NEVER one of
|
||||
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
|
||||
* is refused on read and on write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from "./api";
|
||||
|
||||
const TOOL_KEY = "agenthub.defaultTool";
|
||||
const MODE_KEY = "agenthub.permissionMode";
|
||||
|
||||
/** The permission modes a default may be. */
|
||||
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
|
||||
export type DefaultMode = (typeof DEFAULT_MODES)[number];
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}
|
||||
|
||||
/** The account's own default AI tool id, or null (the install's default applies). */
|
||||
export const readDefaultTool = (): string | null => read(TOOL_KEY);
|
||||
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
|
||||
|
||||
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
|
||||
export function readDefaultMode(): PermissionMode {
|
||||
const v = read(MODE_KEY);
|
||||
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
|
||||
}
|
||||
export function writeDefaultMode(mode: DefaultMode): void {
|
||||
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file terminalFont — the lane terminal's font, a viewer preference kept in
|
||||
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
|
||||
* terminal opens. `jetbrains` (the default) and `maple` (rounded) are bundled
|
||||
* webfonts that cover every Vietnamese letter; `system` is the browser's own
|
||||
* terminal opens. `jetbrains` (the default) and the rounded `maple` are
|
||||
* bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
|
||||
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
|
||||
* and those glyphs then fall back to a face of another width).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
|
||||
@@ -2661,6 +2661,8 @@ export interface Lane {
|
||||
cwd: string;
|
||||
/** Server mode: the account that owns this workspace (null = nobody yet). */
|
||||
owner_id?: number | null;
|
||||
/** The owner's username (`users.username`); null with no owner. */
|
||||
owner_name?: string | null;
|
||||
branch: string | null;
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
@@ -3205,6 +3207,9 @@ export interface TranscriptContent {
|
||||
output?: string;
|
||||
/** Present for "tool_result" blocks: whether the tool call errored. */
|
||||
is_error?: boolean;
|
||||
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||
* removed (from Claude Code's `structuredPatch`). */
|
||||
diff?: { added: number; removed: number };
|
||||
}
|
||||
|
||||
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
|
||||
@@ -3239,6 +3244,9 @@ export interface TranscriptMessage {
|
||||
* Example: `"claude-opus-4-8"`. */
|
||||
model?: string;
|
||||
/** Token accounting reported alongside an assistant message; absent otherwise. */
|
||||
/** Assistant lines: why the model stopped — "end_turn" closes the turn,
|
||||
* "tool_use" means more work follows. Absent from an older server. */
|
||||
stop_reason?: string;
|
||||
usage?: {
|
||||
/** Fresh (non-cached) input tokens for this message. */
|
||||
input_tokens: number;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @file userPrefs.ts
|
||||
* @description An account's UI preferences, kept per ACCOUNT on the server
|
||||
* (`/api/me/prefs`, docs/plans/onboarding) while every component goes on
|
||||
* reading and writing localStorage as it always did. On sign-in `hydrate()`
|
||||
* copies the account's stored values into localStorage (the account wins),
|
||||
* sends up the ones this browser has and the account does not yet, and makes
|
||||
* the theme, language and pet re-read them. From then on a write to one of
|
||||
* `PREF_KEYS` through `localStorage.setItem`/`removeItem` is sent to the
|
||||
* server, batched. `useOnboarding()` tells the setup wizard whether this
|
||||
* account has been through it. Never throws: a server that cannot answer
|
||||
* leaves the browser's own values in charge. The same row also holds
|
||||
* `agenthub.chatUi`, which `lib/chatUi.ts` reads and writes on the server
|
||||
* directly (no localStorage copy).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
import i18n from "../i18n";
|
||||
import { applyStoredTheme } from "../hooks/useTheme";
|
||||
|
||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||
export const PREF_KEYS = [
|
||||
"theme",
|
||||
"agenthub.theme.dark",
|
||||
"agenthub.theme.light",
|
||||
"i18nextLng",
|
||||
"agenthub.terminal.font",
|
||||
"agenthub.pet",
|
||||
"agenthub.pet.size",
|
||||
"agent-dashboard-sagi-enabled",
|
||||
"agent-dashboard-sagi-muted",
|
||||
"agent-monitor-notifications",
|
||||
"agenthub.defaultTool",
|
||||
"agenthub.permissionMode",
|
||||
"agenthub.tour",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
|
||||
// `forced`: Settings asked for the setup again, whatever the account has.
|
||||
type State = { loaded: boolean; onboardedAt: string | null; forced: boolean };
|
||||
let state: State = { loaded: false, onboardedAt: null, forced: false };
|
||||
const listeners = new Set<() => void>();
|
||||
const setState = (next: Partial<State>) => {
|
||||
state = { ...state, ...next };
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
let pending: Record<string, string | null> = {};
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let syncing = false;
|
||||
|
||||
function queue(key: string, value: string | null) {
|
||||
if (!syncing) return;
|
||||
pending[key] = value;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(flush, PUSH_DELAY_MS);
|
||||
}
|
||||
|
||||
/** Send what is queued now (also on page hide). */
|
||||
export function flush(): Promise<void> {
|
||||
clearTimeout(timer);
|
||||
const batch = pending;
|
||||
pending = {};
|
||||
if (!Object.keys(batch).length) return Promise.resolve();
|
||||
return api.me
|
||||
.savePrefs(batch)
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
/* offline or signed out: the browser keeps its own copy */
|
||||
});
|
||||
}
|
||||
|
||||
let wrapped = false;
|
||||
function wrapStorage() {
|
||||
if (wrapped || typeof Storage === "undefined") return;
|
||||
wrapped = true;
|
||||
const proto = Storage.prototype;
|
||||
const setItem = proto.setItem;
|
||||
const removeItem = proto.removeItem;
|
||||
proto.setItem = function (key: string, value: string) {
|
||||
setItem.call(this, key, value);
|
||||
if (this === window.localStorage && SYNCED.has(key)) queue(key, String(value));
|
||||
};
|
||||
proto.removeItem = function (key: string) {
|
||||
removeItem.call(this, key);
|
||||
if (this === window.localStorage && SYNCED.has(key)) queue(key, null);
|
||||
};
|
||||
window.addEventListener("pagehide", () => void flush());
|
||||
}
|
||||
|
||||
function readLocal(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Make what reads these keys read them again. */
|
||||
function reapply(prefs: Record<string, string>) {
|
||||
// Painted now, not only on the next useTheme render: the login page wore the OS's.
|
||||
applyStoredTheme();
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
window.dispatchEvent(new Event("sagi:prefs"));
|
||||
const lng = prefs.i18nextLng;
|
||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the signed-in account's preferences into this browser and start
|
||||
* sending changes. Call once a login (or local mode) is known.
|
||||
*/
|
||||
export async function hydrate(): Promise<void> {
|
||||
syncing = false;
|
||||
pending = {};
|
||||
let answer;
|
||||
try {
|
||||
answer = await api.me.prefs();
|
||||
} catch {
|
||||
// No answer (an older server, a test with no API): no wizard, no sync —
|
||||
// the browser's own values stay in charge.
|
||||
return;
|
||||
}
|
||||
const up: Record<string, string> = {};
|
||||
for (const key of PREF_KEYS) {
|
||||
const theirs = answer.prefs[key];
|
||||
if (typeof theirs === "string") {
|
||||
try {
|
||||
window.localStorage.setItem(key, theirs);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
} else {
|
||||
const mine = readLocal(key);
|
||||
if (mine !== null) up[key] = mine;
|
||||
}
|
||||
}
|
||||
wrapStorage();
|
||||
syncing = true;
|
||||
reapply(answer.prefs);
|
||||
if (Object.keys(up).length) {
|
||||
pending = { ...up };
|
||||
void flush();
|
||||
}
|
||||
setState({ loaded: true, onboardedAt: answer.onboardedAt });
|
||||
}
|
||||
|
||||
/** The first-run setup is done or skipped, for this account. */
|
||||
export async function markOnboarded(): Promise<void> {
|
||||
await flush();
|
||||
const at = new Date().toISOString();
|
||||
setState({ onboardedAt: at, forced: false });
|
||||
try {
|
||||
const r = await api.me.onboarded();
|
||||
setState({ onboardedAt: r.onboardedAt || at });
|
||||
} catch {
|
||||
/* shown again next time — better than never saving the choices */
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the setup again (Settings → System). */
|
||||
export function resetOnboarding(): void {
|
||||
setState({ onboardedAt: null, forced: true });
|
||||
}
|
||||
|
||||
const subscribe = (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
};
|
||||
|
||||
/** `{loaded, onboardedAt, forced}` for the account signed in now. */
|
||||
export function useOnboarding(): State {
|
||||
return useSyncExternalStore(subscribe, () => state);
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function __resetUserPrefs(): void {
|
||||
state = { loaded: false, onboardedAt: null, forced: false };
|
||||
pending = {};
|
||||
syncing = false;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -57,15 +57,18 @@ import "@fontsource/inter/latin-600.css";
|
||||
import "@fontsource/inter/latin-700.css";
|
||||
import "@fontsource/jetbrains-mono/latin-400.css";
|
||||
import "@fontsource/jetbrains-mono/latin-500.css";
|
||||
import "@fontsource/jetbrains-mono/latin-700.css";
|
||||
// The lane terminal renders live Vietnamese output in this font (TerminalView),
|
||||
// which the latin subset above doesn't cover — without it, missing glyphs fall
|
||||
// back to another installed face with a different advance width and get
|
||||
// visually mangled inside xterm's fixed-width cells.
|
||||
import "@fontsource/jetbrains-mono/vietnamese-400.css";
|
||||
import "@fontsource/jetbrains-mono/vietnamese-500.css";
|
||||
import "@fontsource/jetbrains-mono/vietnamese-700.css";
|
||||
// The rounded terminal font (Settings → Appearance). Its one file already holds
|
||||
// the Vietnamese glyphs; a face is only downloaded once something uses it.
|
||||
import "@fontsource/maple-mono/400.css";
|
||||
import "@fontsource/maple-mono/500.css";
|
||||
import "@fontsource/maple-mono/700.css";
|
||||
import App from "./App";
|
||||
import { AuthGate } from "./components/AuthGate";
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
|
||||
* as a menu; the buttons stay.
|
||||
*
|
||||
* In server mode a profile is SHARED (an admin's, usable by everyone) or
|
||||
* PERSONAL (the signed-in user's own, badged "Personal"). A member edits only
|
||||
* their own and every profile they add is personal; an admin chooses when
|
||||
* adding; an analyst only reads.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||
* "Saved" and "works" are different claims, and the gap between them is only
|
||||
@@ -43,13 +48,15 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../compone
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
|
||||
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs"> & {
|
||||
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
|
||||
apiKey: string;
|
||||
behavesAs: string;
|
||||
models: Partial<AiProxyModels>;
|
||||
scope: "shared" | "personal";
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
@@ -59,10 +66,17 @@ const emptyForm = (): FormState => ({
|
||||
model: "",
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
scope: "shared",
|
||||
});
|
||||
|
||||
export function AiProxyConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
// Server mode's signed-in user; null in local mode, where the host owns
|
||||
// every (shared) profile.
|
||||
const { state: auth } = useAuth();
|
||||
const user = auth.mode === "server" ? auth.user : null;
|
||||
const isAdmin = !user || user.role === "admin";
|
||||
const canWrite = (item: AiProxy) => !item.builtin && (item.scope === "personal" || isAdmin);
|
||||
const [items, setItems] = useState<AiProxy[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<AiProxy | null>(null);
|
||||
@@ -131,6 +145,7 @@ export function AiProxyConfig() {
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
behavesAs: item.behavesAs ?? "",
|
||||
scope: item.scope ?? "shared",
|
||||
});
|
||||
setFormError(null);
|
||||
setOpen(true);
|
||||
@@ -187,6 +202,7 @@ export function AiProxyConfig() {
|
||||
behavesAs: form.behavesAs.trim(),
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
if (user && isAdmin && !editing) body.scope = form.scope;
|
||||
|
||||
if (editing) await api.aiProxies.update(editing.name, body);
|
||||
else await api.aiProxies.create(body);
|
||||
@@ -253,13 +269,18 @@ export function AiProxyConfig() {
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.name}
|
||||
onContextMenu={item.builtin ? undefined : (e) => ctx.open(e, item)}
|
||||
onContextMenu={canWrite(item) ? (e) => ctx.open(e, item) : undefined}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||
{item.label}
|
||||
{item.scope === "personal" && (
|
||||
<span className="rounded bg-accent/15 px-1.5 py-px text-[10px] font-normal text-accent">
|
||||
{t("aiProxy.personal")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.builtin && (
|
||||
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
||||
@@ -277,7 +298,7 @@ export function AiProxyConfig() {
|
||||
{item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{!item.builtin && (
|
||||
{canWrite(item) && (
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -349,6 +370,31 @@ export function AiProxyConfig() {
|
||||
value={form.label}
|
||||
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
||||
/>
|
||||
{user && !editing && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="proxy-scope">
|
||||
{t("aiProxy.fieldScope")}
|
||||
</label>
|
||||
{isAdmin ? (
|
||||
<select
|
||||
id="proxy-scope"
|
||||
value={form.scope}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, scope: e.target.value as FormState["scope"] }))
|
||||
}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
<option value="shared">{t("aiProxy.scopeShared")}</option>
|
||||
<option value="personal">{t("aiProxy.scopePersonal")}</option>
|
||||
</select>
|
||||
) : (
|
||||
<p id="proxy-scope" className="text-xs text-fg-primary">
|
||||
{t("aiProxy.scopePersonal")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("aiProxy.fieldScopeHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
<Field
|
||||
id="proxy-base-url"
|
||||
label={t("aiProxy.fieldBaseUrl")}
|
||||
|
||||
@@ -140,7 +140,8 @@ const MOVED: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Sections that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
|
||||
const ADMIN_SECTIONS = new Set(["data", "ai-proxy", "pricing"]);
|
||||
// AI Proxy is not here: every user keeps personal profiles (S3).
|
||||
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
|
||||
+242
-106
@@ -7,13 +7,30 @@
|
||||
* an admin — the account changes: add a user, change a role, disable/enable,
|
||||
* reset a password, give a workspace. Every change asks for the admin's OWN
|
||||
* password again (the server refuses without it); an analyst only reads.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
DollarSign,
|
||||
Eye,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Lock,
|
||||
LockOpen,
|
||||
MessagesSquare,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Shield,
|
||||
UserPlus,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||
@@ -22,7 +39,8 @@ import { isSubmitEnter } from "../lib/keys";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { SettingsSection } from "../components/settings/SettingsRow";
|
||||
import { StatCard } from "../components/StatCard";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
@@ -67,12 +85,22 @@ export function Users() {
|
||||
// Local mode, or a developer: there is no Users page for them.
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
const sum = (pick: (u: ServerUserUsage) => number) =>
|
||||
(users ?? []).reduce((n, u) => n + pick(u), 0);
|
||||
const active = (users ?? []).filter((u) => !u.disabled).length;
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -97,115 +125,206 @@ export function Users() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t("title")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
|
||||
{users && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="users-table">
|
||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">{t("name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
|
||||
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
|
||||
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
|
||||
{t(`roles.${u.role}`)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="py-1 text-right whitespace-nowrap">
|
||||
<RowAction
|
||||
label={t("changeRole")}
|
||||
disabled={self}
|
||||
onClick={() => setDialog({ kind: "role", user: u })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("resetPassword")}
|
||||
onClick={() => setDialog({ kind: "password", user: u })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("assignWorkspace")}
|
||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={u.disabled ? t("enable") : t("disable")}
|
||||
disabled={self}
|
||||
onClick={() =>
|
||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
||||
}
|
||||
>
|
||||
{u.disabled ? (
|
||||
<LockOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
</RowAction>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("title")}
|
||||
value={users?.length ?? 0}
|
||||
icon={UsersIcon}
|
||||
trend={users ? t("activeCount", { n: active }) : undefined}
|
||||
loading={!users && !error}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("workspaces")}
|
||||
value={sum((u) => u.workspaces)}
|
||||
icon={FolderOpen}
|
||||
loading={!users && !error}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("sessions")}
|
||||
value={sum((u) => u.sessions)}
|
||||
icon={MessagesSquare}
|
||||
loading={!users && !error}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("cost30d")}
|
||||
value={fmtCost(sum((u) => u.last30d.cost))}
|
||||
icon={DollarSign}
|
||||
trend={users ? `${t("last7d")}: ${fmtCost(sum((u) => u.last7d.cost))}` : undefined}
|
||||
raw={t("tokens", { n: fmt(sum((u) => u.last30d.tokens)) })}
|
||||
loading={!users && !error}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
|
||||
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isAdmin && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||
<Eye className="w-3.5 h-3.5" /> {t("readOnly")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[760px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
<th className={th}>{t("role")}</th>
|
||||
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{!users &&
|
||||
!error &&
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRowSkeleton
|
||||
key={i}
|
||||
columns={isAdmin ? 7 : 6}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
|
||||
/>
|
||||
))}
|
||||
{users?.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{u.username.slice(0, 2)}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-fg-primary">
|
||||
{u.username}
|
||||
</span>
|
||||
{self && (
|
||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||
{t("you")}
|
||||
</span>
|
||||
)}
|
||||
{u.disabled && (
|
||||
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
|
||||
title={t(`roleHint.${u.role}`)}
|
||||
>
|
||||
{t(`roles.${u.role}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.workspaces}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions}
|
||||
</td>
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<RowAction
|
||||
label={t("changeRole")}
|
||||
disabled={self}
|
||||
onClick={() => setDialog({ kind: "role", user: u })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("resetPassword")}
|
||||
onClick={() => setDialog({ kind: "password", user: u })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("assignWorkspace")}
|
||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={u.disabled ? t("enable") : t("disable")}
|
||||
disabled={self}
|
||||
onClick={() =>
|
||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
||||
}
|
||||
>
|
||||
{u.disabled ? (
|
||||
<LockOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
</RowAction>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section className="card">
|
||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<ScrollText className="w-4 h-4 text-fg-muted" />
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("auditTitle")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("auditDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{audit?.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("auditEmpty")}</p>
|
||||
)}
|
||||
{audit && audit.length > 0 && (
|
||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||
<ul
|
||||
className="divide-y divide-border text-xs max-h-[28rem] overflow-y-auto"
|
||||
data-testid="audit-list"
|
||||
>
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex gap-3 py-1.5">
|
||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||
{a.reader} → {a.owner}
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center gap-3 px-5 py-2 hover:bg-surface-4/40 transition-colors"
|
||||
>
|
||||
<span className="w-36 flex-shrink-0 tabular-nums text-fg-muted">
|
||||
{formatDateTime(a.at)}
|
||||
</span>
|
||||
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
|
||||
<span className="font-medium text-fg-primary">{a.reader}</span>
|
||||
<span className="mx-1.5 text-fg-muted">→</span>
|
||||
{a.owner}
|
||||
</span>
|
||||
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
|
||||
{a.method}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted" title={a.path}>
|
||||
{a.path}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</section>
|
||||
|
||||
{dialog && (
|
||||
<AccountDialog
|
||||
@@ -223,6 +342,23 @@ export function Users() {
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_BADGE: Record<Role, string> = {
|
||||
admin: "bg-accent/15 text-accent border-accent/20",
|
||||
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
|
||||
developer: "bg-surface-4 text-fg-secondary border-border",
|
||||
};
|
||||
|
||||
/** A period's cost, with its tokens underneath. */
|
||||
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<td className="px-5 py-3 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
|
||||
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
disabled,
|
||||
|
||||
+166
-105
@@ -15,8 +15,9 @@
|
||||
* a tab click replaces the history entry (Back leaves the session view, not
|
||||
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
|
||||
* splits the focused one right with a new terminal, Alt+W hides it. The
|
||||
* Changes panel resizes by its button's presets or by dragging (or arrow keys
|
||||
* on) its splitter; an unsaved edit in it is asked about before the panel
|
||||
* Changes panel has Full screen (Restore at full screen) and Collapse
|
||||
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
|
||||
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
|
||||
* closes, switches tab, or moves to another workspace or session (it stays on
|
||||
* the old one until saved or discarded). Errors are toasts.
|
||||
*
|
||||
@@ -54,17 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Plus,
|
||||
Columns2,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
ChartColumn,
|
||||
} from "lucide-react";
|
||||
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
LaneSource,
|
||||
LaneSourcesView,
|
||||
RunHandle,
|
||||
RunListResponse,
|
||||
} from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
@@ -109,6 +108,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||
@@ -118,39 +118,37 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the last width the user dragged the panel to — what
|
||||
* it reopens at after the step control closes it. */
|
||||
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
* 30/70 → 50/50 → full → closed, rather than a width button beside a
|
||||
* collapse button that both live in the panel's tightest corner. */
|
||||
const CHANGES_SIZES = ["compact", "half", "full"] as const;
|
||||
/** The Changes panel's two preset widths. Its header has two controls, like a
|
||||
* window's: Full screen (Restore once at full screen, back to the last
|
||||
* dragged width) and Collapse (to the rail). */
|
||||
const CHANGES_SIZES = ["compact", "full"] as const;
|
||||
type ChangesPreset = (typeof CHANGES_SIZES)[number];
|
||||
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
|
||||
type ChangesSize = ChangesPreset | number;
|
||||
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
|
||||
compact: "w-[30%]",
|
||||
half: "w-1/2",
|
||||
compact: "w-[25%]",
|
||||
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
||||
// and a half-typed prompt must survive a round trip through full width.
|
||||
full: "w-full",
|
||||
};
|
||||
/** The icon the one resize control shows AT each size: what the next click
|
||||
* gives you — an even split, the full width, then closed. */
|
||||
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
|
||||
compact: Columns2,
|
||||
half: Maximize2,
|
||||
full: PanelRightClose,
|
||||
};
|
||||
/** The panel's share of the row, for the splitter. */
|
||||
const changesRatio = (s: ChangesSize) =>
|
||||
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
|
||||
/** The preset a size counts as for the button: under ~45% is narrow. */
|
||||
const changesStep = (s: ChangesSize): ChangesPreset =>
|
||||
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
|
||||
const changesRatio = (s: ChangesSize) => (typeof s === "number" ? s : s === "compact" ? 0.25 : 1);
|
||||
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
|
||||
/** The width the user last dragged the panel to, else `compact`. */
|
||||
function lastDraggedChanges(): ChangesSize {
|
||||
try {
|
||||
const n = Number(window.localStorage.getItem(CHANGES_DRAGGED_KEY) || NaN);
|
||||
return Number.isFinite(n) ? clampChanges(n) : "compact";
|
||||
} catch {
|
||||
return "compact";
|
||||
}
|
||||
}
|
||||
/** The panel's own minimum (`min-w-[18rem]`), in px. */
|
||||
const CHANGES_MIN_PX = 288;
|
||||
|
||||
@@ -502,6 +500,7 @@ function ConsoleArea({
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
solo={shown.length === 1}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
@@ -646,17 +645,14 @@ export function Workspace() {
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
|
||||
if (typeof changesSize === "number")
|
||||
window.localStorage.setItem(CHANGES_DRAGGED_KEY, String(changesSize));
|
||||
} catch {
|
||||
/* same as above: a forgotten width, not an error */
|
||||
}
|
||||
}, [changesSize]);
|
||||
const changesFull = changesSize === "full";
|
||||
const changesPreset = changesStep(changesSize);
|
||||
const nextChangesSize =
|
||||
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
|
||||
const changesNextLabel = changesFull
|
||||
? tLanes("changes.collapse")
|
||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||||
const changesSizeLabel = changesFull ? tLanes("changes.restore") : tLanes("changes.size.full");
|
||||
const changesRowRef = useRef<HTMLDivElement>(null);
|
||||
// The row's width: the panel never gets narrower than its 18rem minimum,
|
||||
// so the splitter is drawn — and dragged — no further than that edge.
|
||||
@@ -713,16 +709,49 @@ export function Workspace() {
|
||||
? sessionParam
|
||||
: undefined;
|
||||
|
||||
// The workspace's extra folders: each is its own repo the Changes panel can
|
||||
// show instead (`?source=`), picked in its header. Read per workspace, and
|
||||
// again from the home's Folder list whenever it adds or removes one.
|
||||
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||||
null
|
||||
);
|
||||
const takeGitFolders = useCallback(
|
||||
(laneId: number, view: LaneSourcesView) =>
|
||||
setGitFolders({
|
||||
laneId,
|
||||
items: view.sources.filter((s) => s.kind === "folder" && !s.missing),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (selectedLaneId === null) return;
|
||||
api.lanes.sources(selectedLaneId).then(
|
||||
(v) => takeGitFolders(selectedLaneId, v),
|
||||
() => setGitFolders({ laneId: selectedLaneId, items: [] })
|
||||
);
|
||||
}, [selectedLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
|
||||
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
|
||||
// A pick holds for its own workspace, while that folder is still listed.
|
||||
const gitSource =
|
||||
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
|
||||
? gitSourcePick.id
|
||||
: undefined;
|
||||
|
||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
||||
// session or worktree) would remount it and drop the edit — until the user
|
||||
// says to discard it, or saves.
|
||||
// session, worktree or folder) would remount it and drop the edit — until
|
||||
// the user says to discard it, or saves.
|
||||
const [changesDirty, setChangesDirty] = useState(false);
|
||||
const wantPanel =
|
||||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||||
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
|
||||
selectedLaneId === null
|
||||
? null
|
||||
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
|
||||
const keyOf = (p: typeof wantPanel) =>
|
||||
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
|
||||
const wantPanelKey = keyOf(wantPanel);
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||
const panelKey = keyOf(panelAt);
|
||||
const asking = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wantPanelKey === panelKey) return;
|
||||
@@ -740,6 +769,33 @@ export function Workspace() {
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||
}, [wantPanelKey, panelKey, changesDirty]);
|
||||
// A chat's "Edited N files" card asks for a file: open the panel on it, if
|
||||
// the panel shows that workspace.
|
||||
const [changesRequest, setChangesRequest] = useState<{ path: string; nonce: number } | null>(
|
||||
null
|
||||
);
|
||||
const panelLaneRef = useRef<number | null>(null);
|
||||
panelLaneRef.current = panelAt?.laneId ?? null;
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||
if (laneId !== panelLaneRef.current) return;
|
||||
// Its path is the workspace folder's: back from an extra folder first.
|
||||
setGitSourcePick(null);
|
||||
setChangesOpen(true);
|
||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
}, []);
|
||||
// Asked once: a panel reopened or moved to another tree must not replay it.
|
||||
useEffect(() => {
|
||||
if (!changesOpen) setChangesRequest(null);
|
||||
}, [changesOpen]);
|
||||
// Not on a folder pick: the request above makes one, to land in the panel
|
||||
// it remounts; the picker drops the request itself.
|
||||
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||
useEffect(() => setChangesRequest(null), [panelTreeKey]);
|
||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||
const afterDrop = (fn: () => void) => {
|
||||
if (!changesDirty) return fn();
|
||||
@@ -958,41 +1014,9 @@ export function Workspace() {
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Workspaces are picked in the sidebar's project tree; this page shows
|
||||
the selected one. With none at all, one banner offers to create it. */}
|
||||
{/* Workspaces are picked — and added, with the + — in the sidebar's
|
||||
project tree; this page shows the selected one. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{lanesLoaded && lanes.length === 0 && (
|
||||
/* With no lanes the strip was a dashed tile beside a stray sentence,
|
||||
which reads as a half-rendered row rather than a state. One banner
|
||||
instead — and a compact one on purpose: the run setup form below
|
||||
is what the user actually needs the height for. */
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-empty"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
|
||||
<Layers className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-fg-primary">
|
||||
{tLanes("empty.title")}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{tLanes("empty.body")}{" "}
|
||||
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
|
||||
agenthub workspace add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* The selected lane's pipeline — the thing you actually come to this
|
||||
page to read, beside the list rather than stacked under it.
|
||||
The section itself is ALWAYS rendered, even with no lane selected:
|
||||
@@ -1011,6 +1035,7 @@ export function Workspace() {
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
@@ -1232,7 +1257,7 @@ export function Workspace() {
|
||||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||||
>
|
||||
{/* Header: the title, Analyst, then the resize control — the
|
||||
{/* Header: the title, Analyst, then Full screen and Collapse — the
|
||||
same height as the console's tab bar, so the two read as
|
||||
one header strip. Files and Changes are ONE panel now; its
|
||||
own mode switcher moves between them. */}
|
||||
@@ -1243,6 +1268,34 @@ export function Workspace() {
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* Which repo the panel shows: the workspace's folder or
|
||||
one of its extra folders. Only when it has any. */}
|
||||
{folders.length > 0 && (
|
||||
<select
|
||||
data-testid="panel-git-folder"
|
||||
value={gitSource ?? ""}
|
||||
onChange={(e) => {
|
||||
setChangesRequest(null);
|
||||
setGitSourcePick(
|
||||
e.target.value
|
||||
? { laneId: currentLane.id, id: Number(e.target.value) }
|
||||
: null
|
||||
);
|
||||
}}
|
||||
aria-label={tLanes("panel.folder")}
|
||||
title={tLanes("panel.folder")}
|
||||
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
|
||||
>
|
||||
<option value="">
|
||||
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
|
||||
</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1258,37 +1311,42 @@ export function Workspace() {
|
||||
>
|
||||
<ChartColumn className="h-4 w-4" />
|
||||
</button>
|
||||
{/* A window's two controls: Full screen (Restore at full
|
||||
screen, to the last dragged width) and Collapse. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-resize"
|
||||
onClick={() => {
|
||||
// Past the widest step the next state is "gone", which
|
||||
// is what the rail button reopens at `compact` — and
|
||||
// what drops an unsaved edit, so that asks first.
|
||||
if (changesFull) {
|
||||
afterDrop(() => {
|
||||
setChangesOpen(false);
|
||||
setChangesSize("compact");
|
||||
});
|
||||
} else {
|
||||
setChangesSize(nextChangesSize);
|
||||
}
|
||||
}}
|
||||
// The control names the same region the rail button does,
|
||||
// so a screen reader reads one panel that is open or
|
||||
// closed rather than two unrelated buttons.
|
||||
aria-expanded
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={changesNextLabel}
|
||||
title={changesNextLabel}
|
||||
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
|
||||
aria-label={changesSizeLabel}
|
||||
title={changesSizeLabel}
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{/* Icon-only, and it draws the width the click LANDS on,
|
||||
not the current one — the label the tooltip carries. */}
|
||||
{(() => {
|
||||
const Next = CHANGES_NEXT_ICON[changesPreset];
|
||||
return <Next className="h-4 w-4" />;
|
||||
})()}
|
||||
{changesFull ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-collapse"
|
||||
// Closing drops an unsaved edit, so that asks first; it
|
||||
// reopens from the rail at the last dragged width.
|
||||
onClick={() =>
|
||||
afterDrop(() => {
|
||||
setChangesOpen(false);
|
||||
setChangesSize(lastDraggedChanges());
|
||||
})
|
||||
}
|
||||
// The same region the rail button names, so a screen
|
||||
// reader reads one panel that is open or closed.
|
||||
aria-expanded
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={tLanes("changes.collapse")}
|
||||
title={tLanes("changes.collapse")}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
@@ -1300,9 +1358,11 @@ export function Workspace() {
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
full={changesFull}
|
||||
wide={changesPreset !== "compact"}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={setChangesDirty}
|
||||
openRequest={panelAt.source ? null : changesRequest}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
@@ -1341,6 +1401,7 @@ export function Workspace() {
|
||||
<LaneGitRailCounts
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Tests for the AI Proxy settings page — listing profiles, creating one,
|
||||
* and the two rules that keep a key safe: the form never shows one, and an
|
||||
* edit that leaves the field blank must not send an empty key that would wipe
|
||||
* the stored one.
|
||||
* the stored one. In server mode: a developer or analyst edits only their
|
||||
* personal profiles and adds personal ones, an admin picks shared or personal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +13,12 @@ import userEvent from "@testing-library/user-event";
|
||||
import { AiProxyConfig } from "../AiProxyConfig";
|
||||
import { api } from "../../lib/api";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
||||
import type { AuthState } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
@@ -60,6 +67,7 @@ const nineRouter = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
authState = { mode: "local" };
|
||||
vi.mocked(api.aiProxies.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native] });
|
||||
@@ -286,3 +294,70 @@ describe("AiProxyConfig — testing an endpoint before saving it", () => {
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiProxyConfig in server mode", () => {
|
||||
const shared = { ...nineRouter, scope: "shared" as const };
|
||||
const mine = {
|
||||
...nineRouter,
|
||||
name: "u7_mine",
|
||||
label: "Mine",
|
||||
scope: "personal" as const,
|
||||
};
|
||||
const signIn = (role: "admin" | "developer" | "analyst") => {
|
||||
authState = { mode: "server", user: { id: 7, username: "mia", role } };
|
||||
};
|
||||
|
||||
it("lets a developer edit only their personal profile, badged Personal", async () => {
|
||||
signIn("developer");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] });
|
||||
render(<AiProxyConfig />);
|
||||
await screen.findByText("Mine");
|
||||
expect(screen.getByText("aiProxy.personal")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a developer's profile as personal, with no scope choice", async () => {
|
||||
signIn("developer");
|
||||
const user = userEvent.setup();
|
||||
render(<AiProxyConfig />);
|
||||
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).queryByRole("combobox")).toBeNull();
|
||||
expect(within(dialog).getByText("aiProxy.scopePersonal")).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Mine");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m");
|
||||
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
||||
await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled());
|
||||
expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets an admin choose a personal profile", async () => {
|
||||
signIn("admin");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared] });
|
||||
const user = userEvent.setup();
|
||||
render(<AiProxyConfig />);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /aiProxy\.edit 9Router/ })
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /aiProxy\.add/ }));
|
||||
await user.selectOptions(screen.getByLabelText("aiProxy.fieldScope"), "personal");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Boss");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m");
|
||||
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
||||
await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled());
|
||||
expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBe("personal");
|
||||
});
|
||||
|
||||
it("gives an analyst Add and Edit on personal profiles only", async () => {
|
||||
signIn("analyst");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] });
|
||||
render(<AiProxyConfig />);
|
||||
await screen.findByText("Mine");
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.add/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Workspace.changesPanel.test.tsx
|
||||
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
|
||||
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
|
||||
* @description The Changes panel has two controls: Full screen (Restore at
|
||||
* full screen, back to the last dragged width) and Collapse; the rail reopens
|
||||
* it at the last dragged width, else 25/75. The width survives a reload.
|
||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||
* lane strip's order.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -82,7 +83,22 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
// Workspace 1 has one extra folder, its own repo (`?source=`).
|
||||
sources: vi.fn((id: number) =>
|
||||
Promise.resolve({
|
||||
sources:
|
||||
id === 1
|
||||
? [
|
||||
{ id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false },
|
||||
{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false },
|
||||
]
|
||||
: [],
|
||||
})
|
||||
),
|
||||
gitTree: r({ entries: [] }),
|
||||
// A file opened in the panel on a chat's request.
|
||||
changes: r({ available: false }),
|
||||
gitFile: r({ available: false }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
@@ -104,6 +120,8 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
@@ -150,43 +168,115 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
|
||||
it("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => {
|
||||
await openPanel();
|
||||
const card = () => screen.getByTestId("lane-changes-card");
|
||||
expect(card().className).toContain("w-[30%]");
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-1/2");
|
||||
const size = () => screen.getByTestId("lane-changes-resize");
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
expect(size()).toHaveAttribute("aria-label", "Full screen");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Full width HIDES the console beside it — hidden, still mounted.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-full");
|
||||
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
||||
expect(size()).toHaveAttribute("aria-label", "Restore size");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Past the widest step the panel closes — and reopens at 30/70, not back
|
||||
// at full width, which is why closing resets the width.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
// Restore brings the console back beside it.
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
|
||||
|
||||
// Collapsing from full screen reopens at 25/75, not back at full width.
|
||||
fireEvent.click(size());
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(card().className).toContain("w-[30%]");
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
});
|
||||
|
||||
it("remembers the width across a reload, and never asks the server", async () => {
|
||||
await openPanel();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
|
||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("full");
|
||||
|
||||
cleanup();
|
||||
await openPanel();
|
||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
|
||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-full");
|
||||
});
|
||||
|
||||
it("keeps the rail as the only way back in", async () => {
|
||||
it("restores, and reopens, at the width last dragged to, not the preset", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.changesSize", "full");
|
||||
window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4");
|
||||
await openPanel();
|
||||
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
|
||||
});
|
||||
|
||||
it("opens on a chat's request for a file of the workspace it shows, not another's", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.changesOpen", "0");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
act(() => {
|
||||
openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
|
||||
});
|
||||
|
||||
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
|
||||
await openPanel();
|
||||
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
|
||||
// The workspace folder, then its folders — never a context file.
|
||||
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
|
||||
vi.mocked(api.lanes.git).mockClear();
|
||||
fireEvent.change(pick, { target: { value: "7" } });
|
||||
await settle();
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||
// A chat's file is the workspace folder's: the panel goes back there.
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(pick.value).toBe("");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
||||
});
|
||||
|
||||
it("has no folder picker for a workspace with no extra folders", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses from its narrow width too, back to the rail", async () => {
|
||||
await openPanel();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
lanes: {
|
||||
// The Changes panel's folder picker reads the workspace's extra folders.
|
||||
sources: vi.fn().mockResolvedValue({ sources: [] }),
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes");
|
||||
return { lanes: lanesToReturn, counts: countsToReturn };
|
||||
@@ -434,15 +436,10 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("opens the add-workspace modal from the empty banner when there is none", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
|
||||
lanesToReturn = [];
|
||||
await renderWorkspace();
|
||||
|
||||
await user.click(screen.getByTestId("lane-empty"));
|
||||
expect(
|
||||
await screen.findByText("Create a workspace from a working directory")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the full card only for the selected lane, in the detail panel", async () => {
|
||||
|
||||
@@ -5563,82 +5563,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<button
|
||||
class="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
||||
data-testid="lane-empty"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-layers h-5 w-5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
|
||||
/>
|
||||
<path
|
||||
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
|
||||
/>
|
||||
<path
|
||||
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span
|
||||
class="block text-sm font-medium text-fg-primary"
|
||||
>
|
||||
No workspaces yet
|
||||
</span>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
|
||||
|
||||
<code
|
||||
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
|
||||
>
|
||||
agenthub workspace add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus h-4 w-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
/>
|
||||
<path
|
||||
d="M12 5v14"
|
||||
/>
|
||||
</svg>
|
||||
Add workspace
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="relative flex min-h-0 flex-1"
|
||||
>
|
||||
@@ -5662,6 +5586,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<div
|
||||
class="absolute flex flex-col"
|
||||
data-focused="true"
|
||||
data-menu-bounds="true"
|
||||
data-testid="pane-0"
|
||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||
>
|
||||
@@ -7249,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.5.13 (rebuild client to match)
|
||||
UI build v2.6.0 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -9444,7 +9369,7 @@ exit 0
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.
|
||||
Theme, language, notification settings and pet follow your account; the sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
@@ -9609,7 +9534,7 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.
|
||||
These notification settings are saved to your account. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.
|
||||
</p>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
|
||||
@@ -111,6 +111,8 @@ export default {
|
||||
},
|
||||
backgroundColor: tinted,
|
||||
borderColor: tinted,
|
||||
// Text a step heavier than stock: regular (and `font-normal`) is 500.
|
||||
fontWeight: { normal: "500" },
|
||||
fontFamily: {
|
||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
|
||||
|
||||
+102
-15
File diff suppressed because one or more lines are too long
+4
-2
@@ -105,9 +105,11 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
|
||||
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
|
||||
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
|
||||
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database; `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
|
||||
+13
-14
File diff suppressed because one or more lines are too long
+4
-2
@@ -85,8 +85,10 @@ Internal build — all rights reserved.
|
||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||
It also picks the workspace terminals' font: JetBrains Mono (default), the
|
||||
rounded Maple Mono, or the browser's own fixed-width font (which may lack some
|
||||
Vietnamese letters). Per-browser only.
|
||||
rounded Maple Mono, or the browser's own fixed-width
|
||||
font (which may lack some Vietnamese letters). Per-browser only. Text across
|
||||
the UI and the terminals is one step heavier than stock (regular 500, bold
|
||||
700), and the Chat UI default sits at the top of the page.
|
||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||
Pop, Alert — off by default) played with every notification shown, and each
|
||||
notification type can pick its own.
|
||||
|
||||
+618
-30
@@ -15,12 +15,11 @@ Local mode does not change at all. The login code stays dormant until
|
||||
> **What server mode separates, and what it does not.** In the dashboard and
|
||||
> its API, each developer sees only their own workspaces and the sessions,
|
||||
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
|
||||
> Every terminal pane, though, still runs as the one Linux account that runs
|
||||
> the dashboard. An agent in a pane can therefore read every user's folders,
|
||||
> transcripts and the database straight from disk. Use server mode only with
|
||||
> people you trust until phase S3 (one Linux user per account) lands. S3 and S4
|
||||
> (shared tools) need `sudo` on the host and are planned in the AgentHub-Server
|
||||
> repo.
|
||||
> Until [one Linux user per account](#one-linux-user-per-account) is installed
|
||||
> (`agenthub server install-system`), every terminal pane runs as the one Linux
|
||||
> account that runs the dashboard. An agent in a pane can then read every
|
||||
> user's folders, transcripts and the database straight from disk. Without it,
|
||||
> use server mode only with people you trust.
|
||||
|
||||
## Set it up
|
||||
|
||||
@@ -93,25 +92,32 @@ The login cookie gets `Secure` when the request came over HTTPS
|
||||
start (`server/db.js`), and `member` is no longer accepted.
|
||||
|
||||
"The host's configuration" means a write (anything but GET/HEAD) under
|
||||
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`, `/ai-proxies`,
|
||||
`/ai-routers`, `/git-credentials`, `/ai-tools`, `/import`, `/pricing`,
|
||||
`/budgets`, `/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A developer
|
||||
or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only an admin
|
||||
can do this". An analyst's other writes are scoped like a developer's: one
|
||||
naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
||||
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`,
|
||||
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
||||
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)). A
|
||||
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
|
||||
an admin can do this". An analyst's other writes are scoped like a
|
||||
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
||||
`server/lib/scope.js` limits every non-GET request of an analyst to their own
|
||||
rows).
|
||||
rows), and under S3 an analyst's workspace runs as their own Linux account
|
||||
exactly like a developer's.
|
||||
|
||||
The UI hides what a role cannot use (`usePermissions` in
|
||||
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
|
||||
no Data, Pricing, AI proxy or per-tool pages, no scheduled reports or git
|
||||
credentials, no start-at-login/icon/Quit/Uninstall on System, no alert
|
||||
rules or channels, no install/update/enable on AI tools, no pet upload or
|
||||
delete, and no update check. An analyst's Workspace page and sidebar list only
|
||||
no Data, Pricing or per-tool pages, no scheduled reports or git credentials,
|
||||
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
|
||||
no install/update/enable on AI tools, no pet upload or delete, and no update
|
||||
check; the AI proxy page shows them only their personal profiles' Edit. An
|
||||
analyst's Workspace page and sidebar list only
|
||||
their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
|
||||
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
|
||||
hidden for a session that is not theirs. An analyst's alert feed
|
||||
hidden for a session that is not theirs. The sidebar's workspace list shows an
|
||||
admin or analyst EVERY workspace, grouped under its owner (each lane carries
|
||||
`owner_name`; yours first, no owner last, each group folds): for an analyst
|
||||
another user's workspace there is read only — no menu, rename or new session,
|
||||
no live tabs, and a session opens its analysis page (`/sessions/<id>`). An analyst's alert feed
|
||||
(`GET /api/alerts`) carries `can_ack` on each alert — true only for their own
|
||||
sessions' — and the feed offers Acknowledge only there; Ack all acknowledges
|
||||
only theirs.
|
||||
@@ -140,9 +146,38 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
|
||||
A developer also cannot open the host's own configuration, or any view across all
|
||||
users: Claude Code / Codex config (except the command and skill lists the
|
||||
prompt box uses), git credentials, remote sources, webhooks, reports, metrics,
|
||||
imports, data export, alert rules and `/api/users`. Those return
|
||||
`403 EADMIN`, and Settings hides them.
|
||||
prompt box uses), the AI tools' config files (`/api/ai-tools/<id>/config…`),
|
||||
git credentials, remote sources, webhooks, reports, metrics, imports, data
|
||||
export, alert rules and `/api/users`. Those return `403 EADMIN`, and Settings
|
||||
hides them for developers. The command and skill lists show a developer only their
|
||||
own workspaces' project items: `?cwd=` outside one of them answers
|
||||
`403 EFOLDER`, and with no `cwd` they list no project items at all.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
Each user may keep **personal** AI Proxy profiles beside the **shared** ones:
|
||||
|
||||
- A developer's or analyst's new profile is always personal. An admin's is shared unless
|
||||
they choose **Only me (personal)** in the form (`scope: "personal"`).
|
||||
- A personal profile is named `u<user id>_<slug>`. Only its owner lists,
|
||||
edits, deletes or tests it, and only the owner's workspaces can start a
|
||||
session on it. Anyone else, an admin included, gets `404`, the same answer
|
||||
as a profile that does not exist. A resume of a session that ran on someone
|
||||
else's personal profile goes back to native.
|
||||
- Only an admin writes a shared profile. Everyone can choose one.
|
||||
- **No pane is given a proxy's key, and no route returns one.**
|
||||
- A session's CLI talks to AgentHub's own loopback gateway,
|
||||
`http://127.0.0.1:<port>/api/proxy-gw/<profile>/v1/…`. Its key there is
|
||||
its pane's token (`ANTHROPIC_AUTH_TOKEN` for Claude, `AGENTHUB_PROXY_KEY`
|
||||
for Codex).
|
||||
- AgentHub swaps in the real key and streams the request to the profile's
|
||||
endpoint.
|
||||
- The gateway answers only on this machine, only while the pane lives, and
|
||||
only for the profile the pane was started on.
|
||||
- An agent can still spend through the profile while its pane runs, but it
|
||||
cannot carry the key away.
|
||||
- Claude account-pool tokens are not covered: they still reach the pane (see
|
||||
[Claude account pool](#claude-account-pool)).
|
||||
|
||||
### Keep developers' folders apart
|
||||
|
||||
@@ -152,10 +187,16 @@ AGENTHUB_USERS_ROOT=/srv/agenthub/users
|
||||
```
|
||||
|
||||
With this set, a developer or analyst can create a workspace only inside
|
||||
`/srv/agenthub/users/<username>`; any other folder gets `403 EFOLDER`. A folder
|
||||
`/srv/agenthub/users/<username>`; any other folder gets `403 EFOLDER`. The
|
||||
same root limits a developer's folder browser (`GET /api/lanes/browse`, which
|
||||
opens on their own folder) and `GET /api/lanes/branches`, both judged by real
|
||||
path, so a link cannot point them elsewhere. Under S3 the home itself is no
|
||||
workspace (its dot folders hold the member's logins); use a folder in it. A folder
|
||||
that already belongs to another user's workspace gets `409 EOWNED`. Admins are
|
||||
not limited. Create each user's folder yourself. Until S3 this keeps workspaces
|
||||
apart, not agents.
|
||||
not limited. Without [one Linux user per account](#one-linux-user-per-account)
|
||||
you create each user's folder yourself, and this keeps workspaces apart, not
|
||||
agents. `install-system` sets this value to `/srv/agenthub/users`, and adding a
|
||||
user makes the folder (it is the Linux account's home).
|
||||
|
||||
## What the gate does
|
||||
|
||||
@@ -163,6 +204,9 @@ apart, not agents.
|
||||
under `/api` answers `401 EAUTH` until someone logs in, and the UI shows the
|
||||
login page. Paths are checked the way Express routes them: decoded, with
|
||||
repeated slashes collapsed, and case-insensitive.
|
||||
- The login page follows the OS's light/dark. After a login the dashboard
|
||||
wears that account's own appearance (Settings → Appearance, saved in
|
||||
`/api/me/prefs`); an account that never chose one follows the OS.
|
||||
- Hooks (`/api/hooks/*`) are accepted only from this machine: a loopback socket
|
||||
with no forwarding header. A request through the proxy gets
|
||||
`403 ELOCALONLY`. `POST /api/hooks/ingest-batch` keeps its own bearer token.
|
||||
@@ -187,9 +231,17 @@ apart, not agents.
|
||||
as the owner of the pane's workspace. Each pane the dashboard starts gets its
|
||||
own `AGENTHUB_PANE_TOKEN` through tmux `-e`, and the database stores only its
|
||||
hash (`dashboard_runs.pane_token_hash`). The token is accepted only from this
|
||||
machine and only while that pane is alive, as header `x-agenthub-pane-token`.
|
||||
machine and only while that pane is alive, as header `x-agenthub-pane-token`
|
||||
(with one Linux user per account on: only from the Linux account that pane
|
||||
runs as, read from the kernel like a hook's sender).
|
||||
A workspace with no owner has no such user. Give old workspaces an owner with
|
||||
`agenthub users assign <workspace id> <user>`.
|
||||
`agenthub users assign <workspace id> <user>`. With one Linux user per
|
||||
account on, a workspace inside a member's home can be owned by that member
|
||||
only, and one outside the users root (its panes run as the host account) by
|
||||
an admin only: creating, assigning or starting one against that answers
|
||||
`409 EOWNERFOLDER` — by the user's role at that moment, and demoting or
|
||||
disabling a user ends their panes that run as the host. A pane's folder must
|
||||
be its workspace's account's too (`409 ECWDACCOUNT`).
|
||||
- Named locks (`agenthub lock …`) are per user: a developer's lock names live in
|
||||
their own namespace, so one user's agents never see or take another's.
|
||||
- Browser push subscriptions belong to the user who made them. A
|
||||
@@ -262,9 +314,545 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
- `GET /api/accounts` returns the same table, without tokens.
|
||||
|
||||
> **Ceiling:** anyone with a terminal in AgentHub can read the token of the
|
||||
> account their pane runs on (`echo $CLAUDE_CODE_OAUTH_TOKEN`), and, as one
|
||||
> Linux user, every file of the pool. Hiding tokens from panes needs one Linux
|
||||
> user per account (phase S3), or AgentHub acting as the pane's API endpoint.
|
||||
> account their pane runs on (`echo $CLAUDE_CODE_OAUTH_TOKEN`). Without one
|
||||
> Linux user per account they can also read every file of the pool. With it,
|
||||
> they still see their own pane's token (a member's pane gets it through its
|
||||
> launch file instead of tmux `-e`), but no other account's process or file.
|
||||
> The AI Proxy gateway does not cover pool tokens.
|
||||
|
||||
## Codex account pool
|
||||
|
||||
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
|
||||
|
||||
```bash
|
||||
# An OpenAI API key (typed hidden, or piped on stdin)
|
||||
agenthub accounts add team-key --tool codex --api-key
|
||||
# A ChatGPT account: sign in in a scratch home, then hand that home over
|
||||
CODEX_HOME=$(mktemp -d) codex login
|
||||
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
|
||||
agenthub accounts list --tool codex
|
||||
agenthub accounts remove team-key --tool codex
|
||||
```
|
||||
|
||||
- **API key**: any number of panes at once. The pane gets the key through
|
||||
tmux `-e AGENTHUB_POOL_KEY=…`, and Codex is pointed at it with
|
||||
`-c model_providers.agenthub_pool={…env_key="AGENTHUB_POOL_KEY"…}` — the
|
||||
argv names the variable, never the key.
|
||||
- **ChatGPT**: Codex reads this login only from `$CODEX_HOME/auth.json`, so it
|
||||
is used only for a pane that runs as its developer (one Linux user per
|
||||
account): AgentHub writes the account's `auth.json` into that developer's
|
||||
`~/.codex` as them (0600) just before the pane starts.
|
||||
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
||||
- A developer who signed Codex in themselves keeps their own login.
|
||||
- While a pane of theirs runs on a pool account, their next pane uses the
|
||||
same account (one `auth.json` per home).
|
||||
- Codex rotates refresh tokens. Every 10 s AgentHub copies a developer's
|
||||
`auth.json` back into the pool when its `last_refresh` is newer, so a new
|
||||
pane starts from the newest login — only from a home AgentHub placed that
|
||||
account in, never with a `last_refresh` in the future, and only when its
|
||||
`id_token` names the same account and email. The `id_token` signature is
|
||||
not checked: a developer the account was placed with can still spoil it
|
||||
for others until a real refresh from another home wins it back. Several panes may share one account
|
||||
(checked on codex 0.161: an older refresh token still refreshes).
|
||||
- Ranking is the Claude pool's: quota ≥ 90% last, then fewest live Codex
|
||||
panes, then $ in 5 h / 7 d. The quota is the newest `rate_limits` in the
|
||||
rollouts of the sessions that account's runs started (no network), each
|
||||
read only from the `CODEX_HOME` of the account its workspace runs as, and
|
||||
never one dated in the future.
|
||||
- A session on an AI Proxy keeps the proxy and does not use the pool.
|
||||
|
||||
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
||||
> `auth.json`, like the Claude pool's token.
|
||||
|
||||
## One Linux user per account
|
||||
|
||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||
account (`ah-<username>`). An agent in Alice's terminal then cannot read Bob's
|
||||
folders, transcripts or the dashboard's database: the OS stops it, not just the
|
||||
UI. This is phase S3 of `docs/plans/server-mode/S3-PLAN.md`. It is ON when
|
||||
server mode is on AND `/etc/agenthub/server.conf` exists
|
||||
(`server/lib/linux-accounts.js`, `s3Enabled`); without that file everything
|
||||
below is a no-op and server mode behaves as described above. To install it,
|
||||
follow the [operator runbook](#operator-runbook).
|
||||
|
||||
> **Status (2026-10-09):** built and checked on a real machine: the host
|
||||
> setup, the accounts, panes, git and file saves as the member, hooks checked
|
||||
> by account, reads across homes, shared tools in `/opt`, and the fixes from
|
||||
> the 2026-10-08 security review (P8 F1–F11). Built and tested with fake
|
||||
> accounts only: per-member quota (P4), the opt-in plugin baseline (P6) and
|
||||
> the Codex account pool (P7). The second review (2026-10-09, P2–P7) is fixed
|
||||
> (see [What the second review closed](#what-the-second-review-closed)).
|
||||
>
|
||||
> Not done yet (`docs/plans/server-mode/S3-PLAN.md`):
|
||||
> - the admin's plugin baseline through managed settings (P6 spike, not run):
|
||||
> the opt-in fallback ([Plugin baseline](#plugin-baseline-opt-in)) is
|
||||
> built; otherwise a member installs plugins in their own terminal
|
||||
> (`/plugin`);
|
||||
> - Codex and Antigravity hooks for members: provisioning installs none, so a
|
||||
> member's Codex and Antigravity sessions are tracked from their journals
|
||||
> (the pollers read every member's home), not from hooks;
|
||||
> - per-member quota, the plugin baseline and the Codex account pool (P4, P6,
|
||||
> P7) are not yet tried with real member accounts or a real Codex login.
|
||||
|
||||
### The model
|
||||
|
||||
- **Identity.** AgentHub user `alice` is Linux user `ah-alice`, home
|
||||
`/srv/agenthub/users/alice` (`0750`, owner `ah-alice`, group `agenthub`).
|
||||
The name must be lower case: a letter, then up to 28 letters, digits, `_` or
|
||||
`-` (`LINUX_NAME_RE`). While S3 is on, a user with any other name cannot be
|
||||
created (`400 EBADUSER`). Admins and analysts get a Linux account too. A
|
||||
workspace's folder decides who runs it (`server/lib/run-as.js`,
|
||||
`accountForPath`): a folder under `/srv/agenthub/users/<name>/` runs as
|
||||
`ah-<name>`; any other folder runs as the host account, as before (a
|
||||
workspace made before S3 outside the users root, for example). A folder under the users root whose
|
||||
user is unknown, disabled or not provisioned is an error (`409
|
||||
EMEMBERGONE`), never "run it as the host".
|
||||
- **The dashboard.** A SYSTEM unit, `agenthub.service`, runs it as the host
|
||||
account with `CAP_DAC_READ_SEARCH` (it reads every member's tool files —
|
||||
Claude writes transcripts `0600` — but writes none of them). Every program
|
||||
it starts goes through `setpriv --inh-caps=-all --ambient-caps=-all` first
|
||||
(`server/lib/drop-caps.js`), so host panes, git hooks and npm do not inherit
|
||||
the capability.
|
||||
- **Run-as through sudo.** Everything done AS a member is
|
||||
`sudo -n -u ah-<name> <command>`, and the sudoers file allows exactly
|
||||
`tmux`, `git`, `gh` (by the absolute paths in `server.conf`),
|
||||
`/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the
|
||||
plugin baseline) that way — never root. As root, the host may
|
||||
run only `/opt/agenthub/sbin/agenthub-provision` (create, disable or enable
|
||||
a member) and `systemctl start|stop|restart|enable|disable
|
||||
agenthub.service`. Members are never in sudoers.
|
||||
- **sudo logs every command line**, so nothing secret or private crosses it:
|
||||
a pane's environment (its pane token, a pool token) and its command (the
|
||||
prompt) go into a launch file that `write-as` writes as the member (`0600`,
|
||||
from stdin) and `/opt/agenthub/libexec/pane-launch` reads, deletes and
|
||||
execs. File contents go to `write-as` on stdin.
|
||||
- **hidepid is required.** `/proc` is mounted `hidepid=invisible`, with group
|
||||
`agenthub-proc` (the host account, `polkitd`) exempt. Without it any account
|
||||
could read another's command lines. `install-system` stops if the remount
|
||||
fails.
|
||||
- **Hooks are authenticated by the sender's uid**, read from the kernel's
|
||||
table of loopback connections (`/proc/net/tcp*`, `server/lib/hook-auth.js`),
|
||||
not from anything the hook says.
|
||||
- **The AI Proxy gateway.** A pane on an AI Proxy profile gets
|
||||
`http://127.0.0.1:<port>/api/proxy-gw/<profile>` and its own pane token as
|
||||
the key; AgentHub swaps in the real key upstream
|
||||
(`server/lib/proxy-gateway.js`, see [AI Proxy profiles](#ai-proxy-profiles)).
|
||||
A Claude pane gets the token in a per-run `--settings` file, so a user or
|
||||
project `env.ANTHROPIC_*` cannot override it.
|
||||
- **Shared tools.** Members run every AI tool, node and AgentHub's own scripts
|
||||
from `/opt/agenthub` (`server/lib/shared-tools.js`), first on their pane's
|
||||
`PATH`, with self-update off (`DISABLE_AUTOUPDATER=1`).
|
||||
|
||||
### What install-system sets up
|
||||
|
||||
The pieces, and what each is for (the install steps are in the
|
||||
[runbook](#install)):
|
||||
|
||||
| Piece | What it is |
|
||||
| --- | --- |
|
||||
| `ah-<name>` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/<name>` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) |
|
||||
| groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) |
|
||||
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
|
||||
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
|
||||
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
|
||||
| `write-as`, `pane-launch` | `/opt/agenthub/libexec/`, root's. `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
|
||||
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
|
||||
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
|
||||
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
|
||||
| `/opt/agenthub/{tools,app}` | Owned by the host account: the dashboard updates them, every account runs them. `tools/bin` holds node and every installed AI tool; this install's hook handler, CLI, MCP server and statusline go in `app`, with the `agenthub` launcher in `tools/bin`. The AI tools are published there after each install or update from the AI Tools page, at server start, and by `agenthub server tools`. A publish copies a native binary, an npm package's folder, or a script's own version folder. A tool that is not published yet answers a member's start with `409 ENOTSHARED` |
|
||||
|
||||
What runs as the member, and how:
|
||||
|
||||
- **Panes.**
|
||||
- Each member has their own tmux server (`~/.agenthub/tmux/sock`). Every tmux command on it, attaching a browser terminal included, runs as `sudo -u ah-<name> tmux …`. The dashboard never connects to it as itself: the member controls that socket.
|
||||
- sudo logs every command line it runs, so a pane's environment (its tokens) and its command (the prompt) are not on it. `write-as` writes them to a launch file (`0600`, from stdin), and `/opt/agenthub/libexec/pane-launch` reads it, deletes it and starts the command. A prompt pasted later goes in on stdin.
|
||||
- A pane gets the member's `HOME`, `CODEX_HOME` and `PATH` (`/opt/agenthub/tools/bin` first), with `DISABLE_AUTOUPDATER=1` and none of the host's desktop variables.
|
||||
- **Git, `gh` and file saves.**
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the dashboard is refused (`400 EMEMBERCRED`): the member signs in to their git host from their own terminal.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
- files dropped into the prompt box;
|
||||
- a pane's launch file.
|
||||
|
||||
Codex gets its proxy as `-c` overrides, never a file in the member's `CODEX_HOME`.
|
||||
- **Reading a member's files.** A transcript, a Codex, Antigravity or Cursor journal, or a file in the workspace is read only when the opened file is a regular file the member owns. A link to another account's file is refused, and so is a FIFO. A journal speaks only for its own account: its session's folder must be inside that home. A member's Antigravity conversation (SQLite, which opens its `-journal`/`-wal` by name) is read from a private copy; the walk that turns a Cursor project slug back into a folder follows no link and gives up after 500 folders.
|
||||
- **Hooks.** A hook is matched to its sender's Linux account, read from the kernel's table of loopback connections.
|
||||
- A member's Claude Code reports through `/etc/claude-code/managed-settings.json` → `/opt/agenthub/libexec/member-hook` → `/opt/agenthub/app/scripts/hook-handler.js`.
|
||||
- The host's hooks are accepted as before.
|
||||
- A member's hook may report only work inside their own home: another account's folder, session or pane is `403 EFOREIGN`, and a transcript path outside the home is dropped.
|
||||
- Any other account is `403 EPEER`.
|
||||
- **Liveness.** A member's session counts as alive while the pane holding it lives, because the dashboard cannot read another account's process directory. A session the member started in their own shell ends once it has been idle for the idle timeout.
|
||||
|
||||
Not offered for a workspace in a member's folder (`403 EMEMBERLANE`): the
|
||||
Add-workspace **Worktree** mode and the runtime layer (`lanes up/down/hook`,
|
||||
profiles, proof, lane MCP). Both run as the dashboard's own account. Use
|
||||
**Repo** mode and the prompt box's **New worktree** instead.
|
||||
|
||||
**First sign-in.** Every account gets AgentHub's own setup once, in the
|
||||
browser: the default agent and permission mode, the look (System, Dark or
|
||||
Light, a theme for each, language, terminal font), the pet and notifications,
|
||||
then a guided tour to the first workspace and session. The choices are stored
|
||||
per account (`/api/me/prefs`), so they follow the user to any browser; an
|
||||
account that already has a workspace skips it, and Settings → Appearance →
|
||||
**Run setup again** brings it back.
|
||||
|
||||
A member's own Claude Code skips its first-run questions: `agenthub-provision`
|
||||
marks its onboarding done and trusts the member's home (which covers every
|
||||
folder under it), and the dark/light mode picked in AgentHub becomes Claude's
|
||||
theme. Accepting Bypass Permissions mode stays the member's own choice, made
|
||||
in their terminal.
|
||||
|
||||
### Plugin baseline (opt-in)
|
||||
|
||||
Write `/etc/agenthub/plugin-baseline.json` (as
|
||||
root) to give every NEW member your plugins:
|
||||
|
||||
```json
|
||||
{ "marketplaces": ["owner/repo"], "plugins": ["name@marketplace"] }
|
||||
```
|
||||
|
||||
After a user is created, AgentHub runs, as that member and in the
|
||||
background, `claude plugin marketplace add <source>` for each marketplace and
|
||||
then `claude plugin install --scope user <plugin>` for each plugin; each
|
||||
result is logged as `[plugin-baseline]`. `agenthub users provision <name>`
|
||||
(or `--all`) runs it again in the foreground. No file, nothing runs. `-y` is
|
||||
never passed, so a plugin that installs by running a marketplace-declared
|
||||
command fails here; the member installs it from their own terminal. Members
|
||||
add their own plugins with `/plugin` as usual.
|
||||
|
||||
### What the second review closed
|
||||
|
||||
The 2026-10-09 review looked for ways a member can point the dashboard's own
|
||||
rights (`CAP_DAC_READ_SEARCH`, the host account, sudo as any member) at files
|
||||
that are not theirs. Each case below is refused, with a test in
|
||||
`server/__tests__/server-s3-review2.test.js`:
|
||||
|
||||
- **A workspace folder swapped for a link.** The file view, image view, blame
|
||||
and the file editor resolve the workspace's real path and refuse it
|
||||
(`403 EFOREIGN`) when it lands in another account's files — another
|
||||
member's folder, the host's home, `/etc`. The account a read or save runs as
|
||||
is never taken from where a link points.
|
||||
- **A member's git dir naming a path outside their home.** The `info/exclude`
|
||||
line AgentHub adds for a new worktree or `.mcp.json` is written only when the
|
||||
repo's real git dir is the same account's; a FIFO there cannot hang the
|
||||
dashboard.
|
||||
- **Browsing and branch lists.** A developer's `GET /api/lanes/browse` and
|
||||
`GET /api/lanes/branches` stay inside their own folder (`403 EFOLDER`), and
|
||||
so does a workspace's extra folder (`POST /api/lanes/:id/sources/folder`,
|
||||
refused before the path is looked at, so the answer says nothing about
|
||||
another member's files).
|
||||
- **The top of a member's home.** Its dot folders (`.claude`, `.codex`,
|
||||
`.config`, `.ssh`, `.agenthub`…) hold logins and pane tokens: no workspace
|
||||
file view serves them to anyone (`403 ESECRET`), and the home itself is no
|
||||
workspace.
|
||||
- **Transcripts by session id.** A scan by id looks only in the session
|
||||
owner's home, so a file another member plants under that id is never shown.
|
||||
- **Codex journals.** A rollout journal sets the status only of a lane in its
|
||||
own account's home, and the newest-40 window is per home.
|
||||
- **A disabled member's sessions.** Opening one reads from their home instead
|
||||
of crashing the server, and the liveness reaper skips them instead of
|
||||
stopping.
|
||||
- **Host terminals.** A browser terminal's `tmux attach` (node-pty, which
|
||||
forks outside `child_process`) is started through `setpriv` too, so it does
|
||||
not keep `CAP_DAC_READ_SEARCH`.
|
||||
- **AI Proxy edits.** Editing a profile rewrites a running gateway pane's own
|
||||
settings file (with its own token), and deletes the file of a pane that has
|
||||
ended.
|
||||
|
||||
### What review round 5 closed
|
||||
|
||||
The fifth review (2026-10-09) looked at the dashboard's ONE thread and at who
|
||||
a pane really is. Each case below is refused or bounded, with a test in
|
||||
`server/__tests__/server-s3-review5.test.js`:
|
||||
|
||||
- **AgentHub's data dir is never a config file.** `GET /api/cc-config/file`
|
||||
refuses anything in it — the database, the push keys, and each gateway
|
||||
pane's settings file, which holds that pane's live token.
|
||||
- **A member's stopped or flooding tmux server.** Every call on it has a 3 s
|
||||
limit and a 1 MiB answer cap; one that hit either is not asked again for a
|
||||
minute. Meanwhile only that member's panes count as unknown (kept, not
|
||||
ended); everyone else's are still checked. Listing a member's panes (every
|
||||
poll: the pane list, the workspace list, the 10 s heal beat, the liveness
|
||||
reaper) never waits for them: it runs in the background and the poll reads
|
||||
its last answer, so a stopped server stalls nobody. Until a first answer,
|
||||
or once the last is 15 s old, their panes count as unknown too. A call a
|
||||
click makes on a member's pane (start, type, stop) still waits, up to the
|
||||
3 s. Every other synchronous call
|
||||
that runs as a member (git in their folder, `write-as`) stops after 5 s.
|
||||
- **A member's huge or sparse files.** Transcripts and journals are read line
|
||||
by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
|
||||
a whole member file is read only up to a cap (1 MiB for a login or a
|
||||
settings file, 16 MiB for an index or a workflow journal, 256 MiB for a
|
||||
SQLite copy, 64 MiB otherwise); a member transcript over 256 MiB is neither
|
||||
parsed nor snapshotted, and the snapshot keeps a sparse file's holes. The
|
||||
snapshot copy is asynchronous (1 MiB at a time), so a big one never stalls
|
||||
the dashboard for other users, and it lands by rename — never half-written.
|
||||
- **A request that makes a route throw** answers 500 instead of stopping the
|
||||
server (a member folder of a gone user crashed `POST /api/lanes/worktree`).
|
||||
- **Who a pane runs as.** A non-admin owns no workspace outside their home
|
||||
(above), a pane's folder is its workspace's account's, and a pane token
|
||||
works only from that account's uid.
|
||||
- **A path an analyst names.** Browsing, branch lists and the `@` file search
|
||||
stay in their own folder or workspaces, like a developer's.
|
||||
- **Drops** are saved where the pane that reads them can read them: as the
|
||||
workspace's account (the client names the workspace), not the dropper's.
|
||||
- **The Codex pool and ChatGPT Teams.** A login is the pool's only when
|
||||
AgentHub placed it in that home AND it is the same person (email): every
|
||||
seat of a Team shares one `account_id`, so a member's own seat is theirs.
|
||||
|
||||
### Accepted ceilings
|
||||
|
||||
- The dashboard process can read any file, `/etc/shadow` included. `sudo`
|
||||
needs that file, so the unit cannot hide it (`InaccessiblePaths` hides only
|
||||
`/root`). Treat the host account as privileged, and keep it out of the
|
||||
`sudo` group on a real server: there the sudoers file above is all it may
|
||||
do.
|
||||
- Anything that runs as the host account can run anything as any member: the
|
||||
sudoers file lets it start `tmux` as them. A host compromise is every
|
||||
member's compromise.
|
||||
- `install-system` installs and runs code from the host account's install, as
|
||||
root. Re-run it only from a tree you trust.
|
||||
- sudo's log (readable by root and `adm`) shows members' commit messages,
|
||||
branch names, pull-request titles and file paths, never a token or a prompt.
|
||||
- A member may point a personal AI Proxy at any URL, so the dashboard will
|
||||
POST to it. They have a shell on the machine anyway.
|
||||
- The gateway forwards any path under `/v1/` to the profile's upstream, and an
|
||||
agent can spend through a profile while its pane lives.
|
||||
- A Claude pool token is in its pane's environment: the member of that pane
|
||||
can read it (see [Claude account pool](#claude-account-pool)). The same goes
|
||||
for a Codex pool API key, and for a Codex ChatGPT login written into the
|
||||
member's `~/.codex/auth.json` ([Codex account pool](#codex-account-pool)).
|
||||
- The Codex pool's quota reading comes from rollout journals, which their
|
||||
member writes: a member can forge the reading of their OWN session (say a
|
||||
pool account is full, or empty) and so steer the balancer for everyone
|
||||
until a newer reading wins. They cannot speak for another account's run:
|
||||
a run's rollout is read only from its own account's `CODEX_HOME`, and never
|
||||
one dated in the future.
|
||||
- A member session started in the member's own shell, outside a dashboard
|
||||
pane, counts as alive only until its idle timeout: the dashboard cannot read
|
||||
another account's `/proc/<pid>`.
|
||||
|
||||
## Operator runbook
|
||||
|
||||
Every command below runs as the **host account** (the one that runs the
|
||||
dashboard) unless it says `sudo`. `agenthub server install-system` calls sudo
|
||||
itself, once.
|
||||
|
||||
### What a new machine needs before `install-system`
|
||||
|
||||
`install-system` does not do any of this:
|
||||
|
||||
1. A Linux machine with systemd, kernel ≥ 5.8 (for `hidepid=invisible`),
|
||||
`sudo` with `visudo`, the shadow tools (`useradd`, `usermod`, `groupadd`),
|
||||
util-linux (`runuser`, `setpriv` at `/usr/bin/setpriv`, `mount`), `pkill`,
|
||||
`sha256sum`.
|
||||
2. A host account that is not root, with sudo rights for this one run (take
|
||||
them away again afterwards, see step 9 below).
|
||||
3. Node (the version AgentHub needs), `tmux`, `git` and, if you want it, `gh`
|
||||
on that account's `PATH`. Their absolute paths are recorded once, in
|
||||
`/etc/agenthub/server.conf` and the sudoers file.
|
||||
4. AgentHub installed for the host account, as the plugin or a checkout
|
||||
(`npm run setup && npm run build`), and running once
|
||||
(`agenthub start`).
|
||||
5. Server mode on, with the first admin:
|
||||
`agenthub server setup --hostname agenthub.example.com --admin boss`
|
||||
(see [Set it up](#set-it-up)).
|
||||
6. The AI tools members will use, installed for the host account (the AI
|
||||
Tools page or by hand).
|
||||
7. The HTTPS reverse proxy (see [Put HTTPS in front of it](#put-https-in-front-of-it)).
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
agenthub server install-system --dry-run # print the one sudo command it will run
|
||||
agenthub server install-system # install, then switch to the system unit
|
||||
agenthub users provision --all # Linux accounts for users made earlier (the admin from setup)
|
||||
agenthub server tools # publish the AI tools to /opt now (the server also does it at start)
|
||||
```
|
||||
|
||||
What `agenthub server install-system` does, in order (`installSystem` in
|
||||
`bin/agenthub.js`, root half `scripts/server/install-system.sh`; running it
|
||||
again changes nothing that is already in place):
|
||||
|
||||
1. Refuses to run as root. Resolves, as the host account: its name, node, this
|
||||
install's root, the data dir, the port (`DASHBOARD_PORT`, else 4820) and
|
||||
the paths of `tmux`, `git` and `gh`.
|
||||
2. As root (`sudo bash install-system.sh …`), after checking each value:
|
||||
1. creates groups `agenthub`, `agenthub-members`, `agenthub-proc`; adds the
|
||||
host account to `agenthub` and `agenthub-proc`, and `polkitd` (if it
|
||||
exists) to `agenthub-proc`;
|
||||
2. creates `/srv/agenthub/users` (`0751` root), `/opt/agenthub/{sbin,libexec}`
|
||||
and `/etc/agenthub` (root), `/opt/agenthub/{tools,tools/bin,app}` (the
|
||||
host account's);
|
||||
3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`,
|
||||
`USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`);
|
||||
4. installs `agenthub-provision`, `write-as` and `pane-launch` from
|
||||
`scripts/server/`;
|
||||
5. copies node to `/opt/agenthub/tools/node/<version>` (linked from
|
||||
`tools/bin/node`) and this install's `scripts/` and `server/lib/` to
|
||||
`/opt/agenthub/app`;
|
||||
6. writes `/opt/agenthub/libexec/member-hook` (the port is baked in);
|
||||
7. writes `/etc/sudoers.d/agenthub`, only after `visudo -c` accepts it;
|
||||
8. writes `/etc/claude-code/managed-settings.json` (the member hooks) unless
|
||||
a file that is not AgentHub's is already there;
|
||||
9. `chmod o-rwx` the host's `~/.claude`, and warns when the host's home is
|
||||
open to other accounts;
|
||||
10. adds the `hidepid=invisible,gid=agenthub-proc` line to `/etc/fstab`
|
||||
(backup: `/etc/fstab.agenthub-bak`) and remounts `/proc` with it;
|
||||
11. writes `/etc/systemd/system/agenthub.service` and runs
|
||||
`systemctl daemon-reload`. It does not start it.
|
||||
3. As the host account again: writes `<data dir>/runtime/agenthub.system.env`
|
||||
(node, plugin root, port, client dist), sets `AGENTHUB_USERS_ROOT` in
|
||||
AgentHub's settings file, and disables the `systemd --user` unit (its tmux
|
||||
panes stay).
|
||||
4. Stops a dashboard still running without the system unit, then
|
||||
`sudo -n systemctl enable` and `start --no-block agenthub.service`, and
|
||||
fails unless the unit is active 3 seconds later.
|
||||
|
||||
What a new machine still needs by hand after it:
|
||||
|
||||
1. `agenthub users provision --all`: users created before S3 was on (the
|
||||
admin from `server setup` is one) have no Linux account yet.
|
||||
2. Log out and back in once, so your own shell joins `agenthub` and
|
||||
`agenthub-proc` (the unit gets them on its own).
|
||||
3. `chmod o-rwx ~` for the host account if the installer warned: the database
|
||||
lives under it.
|
||||
4. If the installer left a foreign `/etc/claude-code/managed-settings.json`
|
||||
alone, add the member hooks to it yourself: for each of `PreToolUse`,
|
||||
`PostToolUse` (both with `"matcher": "*"`), `Stop`, `SubagentStop`,
|
||||
`Notification`, `SessionStart`, `SessionEnd` and `UserPromptSubmit`, a
|
||||
`command` hook `/opt/agenthub/libexec/member-hook <Event>` with
|
||||
`"timeout": 5`.
|
||||
5. Any other daemon that must see every process (monitoring agents, for
|
||||
example) needs group `agenthub-proc`: `sudo usermod -aG agenthub-proc <it>`.
|
||||
6. A tool `agenthub server tools` reports as `ESHAREDLAYOUT` (a script in a
|
||||
shared folder) must be installed under `/opt/agenthub/tools` by hand.
|
||||
7. Remove the host account from the `sudo` group (`sudo deluser <host> sudo`
|
||||
on Debian/Ubuntu). Re-running `install-system` later needs it back for
|
||||
that run.
|
||||
8. Pool accounts, if you use a pool: `agenthub accounts add <name>` (Claude)
|
||||
or `agenthub accounts add <name> --tool codex --api-key|--from <dir>`
|
||||
([Codex account pool](#codex-account-pool)).
|
||||
9. Optional: `/etc/agenthub/plugin-baseline.json` for new members' plugins
|
||||
([Plugin baseline](#plugin-baseline-opt-in)).
|
||||
|
||||
### Add, disable and enable a user
|
||||
|
||||
```bash
|
||||
agenthub users add alice [--role admin|analyst|developer] # or the Users page
|
||||
agenthub users disable alice
|
||||
agenthub users enable alice
|
||||
agenthub users provision alice # the Linux account only (a user made before S3, or after a failed create)
|
||||
```
|
||||
|
||||
- **Add** creates the Linux account FIRST (`agenthub-provision create`):
|
||||
`useradd ah-alice` with home `/srv/agenthub/users/alice`, group
|
||||
`agenthub-members`; the home is set to `ah-alice:agenthub 0750`; as the
|
||||
member it makes `~/.agenthub/{tmux,handoff}` (`0700`) and marks Claude
|
||||
Code's onboarding done and the home trusted in `~/.claude.json`. If any of
|
||||
that fails, no AgentHub user is made (`502 EPROVISION`, with the helper's own
|
||||
words). An account `ah-alice` that already exists outside
|
||||
`agenthub-members`, or an existing folder `/srv/agenthub/users/alice` with no
|
||||
account, is refused. With a [plugin baseline](#plugin-baseline-opt-in)
|
||||
file in place, its plugins are then installed as the member in the
|
||||
background.
|
||||
- **Disable** disables the AgentHub user and ends their logins FIRST, then
|
||||
locks the Linux account (`usermod --lock --expiredate 1`) and kills every
|
||||
process of `ah-alice` (`pkill -KILL -u`: their tmux server and every pane).
|
||||
A failed lock still answers `502 EPROVISION`, but the logins are already
|
||||
gone. A user with no Linux account (made before S3, or a name that does not
|
||||
fit) is disabled without any sudo. Their files stay.
|
||||
- **Enable** clears the expiry date (when there is a Linux account) and
|
||||
re-enables the AgentHub user.
|
||||
- **Delete**: AgentHub never deletes a user, an account or a home. To remove
|
||||
one for good, disable it, then by hand: `sudo userdel ah-alice`, and move or
|
||||
delete `/srv/agenthub/users/alice` yourself. The AgentHub user row stays.
|
||||
- **A name that does not fit** (upper case, a dot): `provision` refuses it
|
||||
(`provision --all` reports it and goes on with the next user, exit code 1).
|
||||
Create a new user with a fitting name, move the old workspaces' folders into
|
||||
their home (`/srv/agenthub/users/<name>/…`, owned by `ah-<name>`) and add
|
||||
them there again. `agenthub users assign <workspace id> <name>` gives a
|
||||
workspace only to the member whose home holds it, or — for a folder outside
|
||||
the users root, whose panes run as the host — to an admin; anything else
|
||||
answers `EOWNERFOLDER`.
|
||||
|
||||
### Upgrade
|
||||
|
||||
- Update AgentHub as usual (Settings → Updates, `/agenthub-update`). The
|
||||
bootstrap repoints the unit by rewriting
|
||||
`<data dir>/runtime/agenthub.system.env` (a host-owned file, no root) and
|
||||
restarts it with `sudo -n systemctl restart --no-block agenthub.service`.
|
||||
Panes survive (`KillMode=process`).
|
||||
- At start, the server republishes its own scripts to `/opt/agenthub/app`
|
||||
and every installed AI tool to `/opt/agenthub/tools`, in the background. An
|
||||
install or update from the AI Tools page publishes that tool right after.
|
||||
- An update never touches a root-owned file. **Re-run `agenthub server
|
||||
install-system`** when a release changes anything under `scripts/server/`
|
||||
(provision helper, `write-as`, `pane-launch`, the member hook, sudoers, the
|
||||
unit; an install from before the plugin baseline needs it once for the
|
||||
`claude` sudoers entry), when the port changes (the member hook has it baked in), and when
|
||||
`tmux`, `git` or `gh` moves to another path (sudoers allows only the
|
||||
recorded ones).
|
||||
- Keep every other AgentHub server off the same database (a dev server on
|
||||
another port): an older build cannot see members' tmux servers and marks
|
||||
their runs abandoned.
|
||||
|
||||
### Undo and uninstall
|
||||
|
||||
```bash
|
||||
agenthub server install-system --uninstall # back to the systemd --user unit
|
||||
```
|
||||
|
||||
As root it stops, disables and deletes the system unit (only if it is
|
||||
AgentHub's), deletes the sudoers file, the managed settings (only if
|
||||
unchanged since it wrote them), the `/etc/fstab` line (backup:
|
||||
`/etc/fstab.agenthub-bak`; `/proc` is remounted `hidepid=off`),
|
||||
`/opt/agenthub/{sbin,libexec}` and `server.conf`. Then, as the host account,
|
||||
it repoints the `systemd --user` unit at this install and runs `systemctl
|
||||
--user enable --now agenthub`.
|
||||
|
||||
It keeps `/srv/agenthub/users` and every home, the `ah-*` accounts, the groups
|
||||
and `/opt/agenthub/{tools,app}`. It does not stop members' running tmux
|
||||
servers: disable those users first if they should end. With `server.conf`
|
||||
gone, S3 is off and server mode works as without it.
|
||||
|
||||
To remove everything:
|
||||
|
||||
1. `agenthub server install-system --uninstall`
|
||||
2. `agenthub server off`, then `agenthub uninstall --yes` (the Uninstall
|
||||
button refuses in server mode, `ESERVERMODE`).
|
||||
3. By hand, as root, only if you want them gone: `userdel` each `ah-*`
|
||||
account, `groupdel agenthub agenthub-members agenthub-proc`, and delete
|
||||
`/srv/agenthub`, `/opt/agenthub` and `/etc/agenthub`. These hold members'
|
||||
work: AgentHub never deletes them.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Cause, and what to do |
|
||||
| --- | --- |
|
||||
| `install-system: generated sudoers did not validate` | Nothing was installed past that step. Check the tool paths it printed (`--dry-run`). |
|
||||
| `install-system: …/managed-settings.json exists and is not ours` | Members' Claude hooks are not installed; add them by hand (above). |
|
||||
| `install-system` stops at the `/proc` remount | The kernel does not support `hidepid=invisible` (< 5.8). The `/etc/fstab` line is already added: remove it or restore `/etc/fstab.agenthub-bak` before rebooting. |
|
||||
| `a dashboard is still running — stop it first` | A dashboard outside both units holds the port: `agenthub stop`, then run it again. |
|
||||
| `agenthub.service did not stay up` | `sudo journalctl -u agenthub -n 50`. The unit reads `<data dir>/runtime/agenthub.system.env`. |
|
||||
| `502 EPROVISION` when adding a user | The helper's own message says why (name, existing account or folder). No AgentHub user was made. |
|
||||
| `400 EBADUSER` | The username does not fit the Linux rule (above). |
|
||||
| `409 EMEMBERGONE` on a start or a git action | The workspace's folder is under the users root but its user is disabled, unknown or not provisioned: `agenthub users enable <name>` or `agenthub users provision <name>`. |
|
||||
| `409 ENOTSHARED` on a member's start | That tool is not published to `/opt/agenthub/tools` yet: `agenthub server tools`. |
|
||||
| `403 EMEMBERLANE` | Worktree mode and the runtime layer are not offered in a member's folder; use Repo mode and the prompt box's New worktree. |
|
||||
| `400 EMEMBERCRED` | A member signs in to their git host from their own terminal; AgentHub's stored git logins are not used for them. |
|
||||
| `403 EPEER` on a hook | The hook came from an account that is neither the host nor an enabled member, or the uid could not be read. |
|
||||
| `403 EFOREIGN` on a hook | A member's hook named a folder, session or pane outside their own home. |
|
||||
| `403 EFOREIGN` on a file view or save | The workspace's folder is now a link into another account's files. Put the real folder back, or make a workspace on the real one. |
|
||||
| A member's runs show as abandoned | Another, older AgentHub server on the same database (see Upgrade). |
|
||||
| `sudo: a password is required` in the server log | The sudoers file is missing, or a tool moved off its recorded path: re-run `install-system`. |
|
||||
| `[drop-caps] setpriv is missing` | Install util-linux's `setpriv` at `/usr/bin/setpriv`; until then the dashboard's children inherit `CAP_DAC_READ_SEARCH`. |
|
||||
| `[plugin-baseline] <name>: claude is not in the shared tools yet` | `claude` is not published to `/opt/agenthub/tools` yet: `agenthub server tools`, then `agenthub users provision <name>`. |
|
||||
| `[plugin-baseline] <name>: … failed` with `sudo: a password is required` | The sudoers file predates the baseline: re-run `install-system`. |
|
||||
| A member's first Claude run asks about Bypass Permissions | Expected: that consent stays the member's own. |
|
||||
|
||||
## Turn it off
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
_Status: written 2026-10-02. Phases 1–5 and 7 implemented and committed
|
||||
(a6bd33b); phase 6 implemented 2026-10-02 the Claude way (D4). Phases run in order, each one change-set on `main`.
|
||||
Comes BEFORE the multi-user server plan (now in the AgentHub-Server repo,
|
||||
https://git.smartgift.io.vn/Smartgift-AI/AgentHub-Server) — that plan's owner column hangs
|
||||
Comes BEFORE the multi-user server plan ([`docs/plans/server-mode/README.md`](../server-mode/README.md))
|
||||
— that plan's owner column hangs
|
||||
off the session ownership built in phase 1 here._
|
||||
|
||||
## Where we stand
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user