Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 241e19b713 | |||
| 685eb748e8 | |||
| 44426faf16 | |||
| 26952ae0d9 | |||
| 649f0599f8 | |||
| 8d2765313c | |||
| 57f02f0d89 | |||
| 50ac0f0daf | |||
| fa74cce3fc | |||
| 3113479cd2 | |||
| 4eb7734ab9 | |||
| 750d74a2af | |||
| 8a6dc6a29e | |||
| c042519bc9 | |||
| 383b9ae231 | |||
| aee4fc190e | |||
| 0547488f13 | |||
| 49f87c4da3 | |||
| 1a6b550e89 | |||
| f8d07dab61 | |||
| 0434ffc750 | |||
| e2d8d314f6 | |||
| 0c8782ea75 | |||
| c042adc51b | |||
| 776ad6466f | |||
| 9c98638ec0 | |||
| aa8ac6cad9 |
+9
-13
File diff suppressed because one or more lines are too long
+143
-18
@@ -2845,18 +2845,18 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"setup|off|status [--hostname name] [--admin name]",
|
||||
"setup|install-system|tools|off|status [--hostname name] [--admin name]",
|
||||
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||
],
|
||||
[
|
||||
"users",
|
||||
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||
"list|add|passwd|role|disable|enable|provision|assign|audit [name]",
|
||||
"Server mode's accounts (add: --role admin|analyst|developer)",
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name] [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex|cursor]",
|
||||
"Claude/Codex/Cursor account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -3812,9 +3812,9 @@ const COMMANDS = (() => {
|
||||
const SUBCOMMANDS = {
|
||||
alerts: ["ack", "ack-all"],
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove"],
|
||||
server: ["setup", "install-system", "tools", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "provision", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
import: ["rescan", "path"],
|
||||
@@ -3905,7 +3905,7 @@ async function newPassword(label) {
|
||||
return first;
|
||||
}
|
||||
|
||||
/** `agenthub server setup|off|status` — turn server mode on or off. */
|
||||
/** `agenthub server setup|install-system|tools|off|status` — turn server mode on or off. */
|
||||
async function cmdServer(sub, flags) {
|
||||
const { settingsEnv, auth } = serverAuth();
|
||||
const file = settingsEnv.settingsFilePath();
|
||||
@@ -4062,7 +4062,7 @@ async function installSystem(flags, settingsEnv) {
|
||||
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();
|
||||
spawnSync("sh", ["-c", 'command -v "$1"', "sh", 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();
|
||||
@@ -4166,14 +4166,25 @@ async function cmdUsers(sub, positional, flags) {
|
||||
case "list":
|
||||
table(
|
||||
["id", "username", "role", "state"],
|
||||
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
|
||||
auth
|
||||
.listUsers()
|
||||
.map((u) => [
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
u.disabled ? "disabled" : u.mustChangePassword ? "active (new password due)" : "active",
|
||||
])
|
||||
);
|
||||
return;
|
||||
case "add": {
|
||||
need();
|
||||
const role = typeof flags.role === "string" ? flags.role : "developer";
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role, {
|
||||
mustChange: true,
|
||||
});
|
||||
console.log(
|
||||
`${c.green("✔")} ${user.username} added (${user.role}) — they choose their own password at first login`
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "passwd":
|
||||
@@ -4286,7 +4297,18 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
}
|
||||
pool.resetQuota();
|
||||
table(
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
|
||||
[
|
||||
"codex account",
|
||||
"kind",
|
||||
"live",
|
||||
"$ 5h",
|
||||
"$ 7d",
|
||||
"tok 5h",
|
||||
"tok 7d",
|
||||
"quota",
|
||||
"state",
|
||||
"pinned",
|
||||
],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4301,22 +4323,109 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
a.full ? "full" : "ok",
|
||||
pinnedUsers("codex", a.name),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub accounts … --tool cursor` — the Cursor account pool (server/lib/cursor-pool.js). */
|
||||
async function cmdCursorAccounts(sub, name) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Create an API key in the Cursor dashboard (Integrations), then paste it.")
|
||||
);
|
||||
const added = pool.addAccount(name, {
|
||||
apiKey: await askPassword(`Cursor API key for ${name}: `),
|
||||
});
|
||||
console.log(`${c.green("✔")} ${added.name} added`);
|
||||
console.log(c.dim(" New Cursor 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 cursor");
|
||||
}
|
||||
table(
|
||||
["cursor account", "live", "pinned"],
|
||||
pool.poolStatus().map((a) => [a.name, a.livePanes, pinnedUsers("cursor", a.name)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex|cursor] —
|
||||
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||
*/
|
||||
async function cmdAccountPin(sub, positional, tool) {
|
||||
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
|
||||
const [first, second] = positional;
|
||||
const username = sub === "pin" ? second : first;
|
||||
if (!username || (sub === "pin" && !first)) {
|
||||
throw new Error(
|
||||
"usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
);
|
||||
}
|
||||
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||
.get(username);
|
||||
if (!user) throw new Error(`no user ${username}`);
|
||||
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
|
||||
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
|
||||
console.log(c.dim(" Pins apply in server mode only."));
|
||||
}
|
||||
if (!pin.account) {
|
||||
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
|
||||
return;
|
||||
}
|
||||
const switched = await pins.applyPin(user.id, tool);
|
||||
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
|
||||
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
|
||||
}
|
||||
|
||||
/** What `applyPin` did, for the CLI. */
|
||||
const SWITCH_NOTE = {
|
||||
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
|
||||
kept: "Their home already holds it.",
|
||||
"own-login":
|
||||
"They signed in with their own login, which is left alone: no pool login is written over it.",
|
||||
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
|
||||
"next-session": "New sessions use it; running ones keep their account until they end.",
|
||||
};
|
||||
|
||||
/** The `pinned` column: who an admin pinned to `account`. */
|
||||
function pinnedUsers(tool, account) {
|
||||
try {
|
||||
return (
|
||||
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
|
||||
.listPins(tool)
|
||||
.filter((p) => p.account === account)
|
||||
.map((p) => p.username)
|
||||
.join(",") || "–"
|
||||
);
|
||||
} catch {
|
||||
return "–";
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||
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> [--tool codex]`);
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex|cursor]`);
|
||||
}
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
if (flags.tool != null && !["claude", "codex", "cursor"].includes(flags.tool)) {
|
||||
throw new Error("--tool is claude (the default), codex or cursor");
|
||||
}
|
||||
if (sub === "pin" || sub === "unpin")
|
||||
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
if (flags.tool === "cursor") return cmdCursorAccounts(sub, name);
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||
@@ -4337,7 +4446,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||
table(
|
||||
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
|
||||
[
|
||||
"account",
|
||||
"live",
|
||||
"$ 5h",
|
||||
"$ 7d",
|
||||
"tok 5h",
|
||||
"tok 7d",
|
||||
"quota 5h",
|
||||
"quota wk",
|
||||
"state",
|
||||
"pinned",
|
||||
],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4356,14 +4476,19 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
: a.quota?.error
|
||||
? `no quota (${a.quota.error})`
|
||||
: "ok",
|
||||
pinnedUsers("claude", a.name),
|
||||
])
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||
const cursor = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (cursor.poolStatus().length) await cmdCursorAccounts("list", null);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
|
||||
c.red(
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
Generated
+38
-26
@@ -29,7 +29,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1251,15 +1251,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2740,6 +2731,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
@@ -5472,35 +5475,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"license": "MIT",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4"
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"license": "MIT",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
||||
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
"react-router": "7.18.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
@@ -5766,6 +5773,11 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file AuthCard.tsx
|
||||
* @description The card the signed-out screens share (the login page, the
|
||||
* first-login password change in `FirstPasswordPage.tsx`): a centred form
|
||||
* with the theme's accent glow and grid behind the brand logo, a title and a
|
||||
* subtitle. While it shows, the OS's light/dark applies (`applySystemTheme`),
|
||||
* since no account's theme has been chosen for it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, type FormEvent, type ReactNode } from "react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
|
||||
export function AuthCard({
|
||||
title,
|
||||
subtitle,
|
||||
onSubmit,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
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">{title}</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{subtitle}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
* `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`).
|
||||
* The login page follows the OS's light/dark (`AuthCard`). An account whose
|
||||
* password an admin gave (`mustChangePassword`) sees only
|
||||
* `FirstPasswordPage` until it picks its own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -20,15 +22,14 @@ import {
|
||||
useCallback,
|
||||
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 { AuthCard } from "./AuthCard";
|
||||
import { FirstPasswordPage } from "./FirstPasswordPage";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
@@ -165,6 +166,17 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
// A password an admin gave: nothing else until the user picks their own
|
||||
// (the server refuses the rest anyway — EPWCHANGE).
|
||||
if (state.mode === "server" && state.user?.mustChangePassword) {
|
||||
return (
|
||||
<FirstPasswordPage
|
||||
user={state.user}
|
||||
onDone={() => updateUser({ mustChangePassword: false })}
|
||||
onLogout={() => void logout()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
@@ -178,14 +190,6 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
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);
|
||||
@@ -201,86 +205,60 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: AuthCard follows the OS.
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<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%)",
|
||||
}}
|
||||
<AuthCard title={t("auth.title")} subtitle={t("auth.subtitle")} onSubmit={submit}>
|
||||
<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)}
|
||||
/>
|
||||
<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. */}
|
||||
</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="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}
|
||||
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"
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</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>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,12 +165,15 @@ export function MenuItem({
|
||||
danger,
|
||||
disabled,
|
||||
testId,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
/** Hover text — e.g. why a disabled item cannot run. */
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -179,6 +182,7 @@ export function MenuItem({
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file FirstPasswordPage.tsx
|
||||
* @description The screen an account signed in with a password an admin GAVE
|
||||
* (created or reset on the Users page — `mustChangePassword`) sees instead of
|
||||
* the app: the current password, a new one that must pass every rule (the
|
||||
* `PasswordRules` checklist ticks them as they are typed) and the same again,
|
||||
* sent to `POST /api/me/password`. On success the app opens; "Sign out"
|
||||
* leaves. The server refuses every other request meanwhile (EPWCHANGE).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AuthUser } from "../lib/api";
|
||||
import { passwordProblems } from "../lib/passwordRules";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { PasswordRules } from "./users/PasswordRules";
|
||||
|
||||
export function FirstPasswordPage({
|
||||
user,
|
||||
onDone,
|
||||
onLogout,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onDone: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mismatch = again.length > 0 && again !== next;
|
||||
const strong = passwordProblems(next, user.username).length === 0;
|
||||
const ready = current.length > 0 && strong && again === next && !busy;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.me.changePassword(current, next);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
setError(
|
||||
code && t(`users:errors.${code}`, { defaultValue: "" })
|
||||
? t(`users:errors.${code}`)
|
||||
: (err as Error).message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (
|
||||
label: string,
|
||||
value: string,
|
||||
set: (v: string) => void,
|
||||
autoComplete: string,
|
||||
autoFocus = false
|
||||
) => (
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete={autoComplete}
|
||||
autoFocus={autoFocus}
|
||||
required
|
||||
value={value}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
title={t("auth.mustChangeTitle")}
|
||||
subtitle={t("auth.mustChangeSubtitle")}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{field(t("auth.currentPassword"), current, setCurrent, "current-password", true)}
|
||||
{field(t("auth.newPassword"), next, setNext, "new-password")}
|
||||
<PasswordRules password={next} username={user.username} />
|
||||
{field(t("auth.confirmPassword"), again, setAgain, "new-password")}
|
||||
{(mismatch || error) && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error ?? t("auth.mismatch")}
|
||||
</p>
|
||||
)}
|
||||
<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={!ready}
|
||||
>
|
||||
{busy ? t("auth.saving") : t("auth.mustChangeSubmit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-center text-sm text-fg-secondary hover:text-fg-primary"
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@
|
||||
* 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.
|
||||
* layer, under anything stacked above it. `useDialog` takes the focus while it
|
||||
* is open and gives it back, and its Escape closes only this, the top dialog.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
|
||||
export function ImageLightbox({
|
||||
src,
|
||||
@@ -20,18 +22,18 @@ export function ImageLightbox({
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panel, onClose);
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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"
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8 outline-none"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -82,6 +82,7 @@ import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
type Mode = "rescan" | "path" | "upload" | "backup";
|
||||
|
||||
@@ -247,13 +248,9 @@ export function ImportHistory() {
|
||||
|
||||
const copyArchiveCmd = async () => {
|
||||
if (!guide) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(guide.archive_command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
if (!(await copyText(guide.archive_command, true))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const progressText = (() => {
|
||||
|
||||
@@ -88,6 +88,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import type { ScopeMode } from "../lib/dataScope";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
/** Data-scope choices in display order (the radio group's arrow-key order). */
|
||||
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
|
||||
@@ -863,7 +864,7 @@ export function RemoteSources() {
|
||||
: t("remoteSources.enable", "Enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
||||
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
|
||||
<MenuItem onSelect={pick((s) => void copyText(s.host))}>
|
||||
{t("rowMenu.copyHost")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
stepRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
@@ -318,7 +319,7 @@ export function PetChat({
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
@@ -505,6 +506,9 @@ export function PetChat({
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
@@ -70,7 +71,7 @@ export function PetNameButton({
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { api } from "../lib/api";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { CopyButton } from "./event-views/primitives";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
export function snapshotUrl(token: string): string {
|
||||
return `${window.location.origin}/snapshot/${token}`;
|
||||
@@ -33,7 +34,7 @@ export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
||||
try {
|
||||
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||
setToken(snapshot.token);
|
||||
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
||||
void copyText(snapshotUrl(snapshot.token));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only (never while applying); "Skip this version" is the one
|
||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
||||
* this page load only — also while an update applies: it keeps running in the
|
||||
* background (the page still reloads itself once the new server is up), and a
|
||||
* failure re-opens the modal with its error; "Skip this version" is the one
|
||||
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||
* and starts a Claude Code session there with the error as its prompt — the
|
||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||
@@ -246,10 +248,13 @@ export function UpdateNotifier() {
|
||||
);
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
// Closing while an update applies only hides it: the apply request and the
|
||||
// poll that reloads the page keep running, and a failure re-opens it.
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
if (!dismissKey) return;
|
||||
if (applying || restarting) toast.info(t("backgroundNote"));
|
||||
setClosedKey(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
}, [applying, restarting, dismissKey, t]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
@@ -261,8 +266,8 @@ export function UpdateNotifier() {
|
||||
setDismissedSha(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
// Escape closes for now — never while an update is applying.
|
||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
||||
// Escape closes for now — an applying update carries on in the background.
|
||||
useDialog(show, panelRef, closeForNow);
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!status?.manual_command) return;
|
||||
@@ -315,6 +320,7 @@ export function UpdateNotifier() {
|
||||
syncFromPayload(s);
|
||||
setRestarting(false);
|
||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||
else setClosedKey(null); // closed while it ran: show why it failed
|
||||
return;
|
||||
}
|
||||
// A new version answering is back too, even if a poll never
|
||||
@@ -359,6 +365,8 @@ export function UpdateNotifier() {
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
// Not applied: re-open a modal closed while it ran, so the error is seen.
|
||||
setClosedKey(null);
|
||||
};
|
||||
|
||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||
@@ -436,7 +444,6 @@ export function UpdateNotifier() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
aria-label={t("later")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* `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. The login page wears the
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance.
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance. A
|
||||
* password an admin gave shows only the "choose your own password" page,
|
||||
* whose button waits for every rule, until the change succeeds.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +20,7 @@ import "../../i18n";
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const prefs = vi.fn();
|
||||
const changePassword = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
@@ -27,7 +30,11 @@ 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: {} }) },
|
||||
me: {
|
||||
prefs: () => prefs(),
|
||||
savePrefs: () => Promise.resolve({ prefs: {} }),
|
||||
changePassword: (c: string, n: string) => changePassword(c, n),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,10 +63,30 @@ beforeEach(() => {
|
||||
me.mockReset();
|
||||
login.mockReset();
|
||||
prefs.mockReset().mockResolvedValue({ prefs: {} });
|
||||
changePassword.mockReset().mockResolvedValue({ ok: true });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("AuthGate", () => {
|
||||
it("makes an account with an admin-given password choose its own first", async () => {
|
||||
const ue = userEvent.setup();
|
||||
me.mockResolvedValue({ mode: "server", user: { ...user, mustChangePassword: true } });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
const save = await screen.findByRole("button", { name: "Save and continue" });
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
await ue.type(screen.getByLabelText("Current password"), "123");
|
||||
await ue.type(screen.getByLabelText("New password"), "weakpass");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "weakpass");
|
||||
expect(save).toBeDisabled();
|
||||
await ue.clear(screen.getByLabelText("New password"));
|
||||
await ue.clear(screen.getByLabelText("Confirm new password"));
|
||||
await ue.type(screen.getByLabelText("New password"), "Green-Tea-42");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "Green-Tea-42");
|
||||
await ue.click(save);
|
||||
expect(changePassword).toHaveBeenCalledWith("123", "Green-Tea-42");
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the app in local mode", async () => {
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
|
||||
@@ -58,14 +58,25 @@ describe("MarkdownPreview", () => {
|
||||
describe("FileViewer — markdown preview", () => {
|
||||
const data = { text: "# Hello\n", truncated: false } as never;
|
||||
|
||||
it("switches a markdown file between raw and preview", async () => {
|
||||
it("opens a markdown file rendered, and switches it to raw and back", async () => {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens it as source when Settings → Appearance says Raw", () => {
|
||||
window.localStorage.setItem("agenthub.markdown.view", "raw");
|
||||
try {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
} finally {
|
||||
window.localStorage.removeItem("agenthub.markdown.view");
|
||||
}
|
||||
});
|
||||
|
||||
it("offers no preview for another file type", () => {
|
||||
|
||||
@@ -238,7 +238,7 @@ describe("StatusBar", () => {
|
||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
expect(personal).toHaveTextContent("Check quota in Settings → AI tools → Profiles");
|
||||
expect(personal).not.toHaveTextContent("in use");
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
|
||||
@@ -298,12 +298,17 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
it("closes while an update applies, and re-opens if it then fails", async () => {
|
||||
let fail: (e: Error) => void = () => {};
|
||||
applyMock.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
fail(new Error("Command failed: npm run build"));
|
||||
expect(await screen.findByText("Command failed: npm run build")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,18 +14,19 @@
|
||||
* and tick the ones to add; or type `name@marketplace` by hand, which is
|
||||
* also where a link the server cannot read (ssh, a private repo) ends up.
|
||||
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
||||
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type MarketplaceInfo } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||
const label = "block text-[11px] font-medium text-fg-secondary";
|
||||
const field = "input w-full";
|
||||
const label = "block text-xs text-fg-secondary";
|
||||
const hint = "text-[11px] text-fg-muted";
|
||||
|
||||
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
||||
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
|
||||
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
|
||||
const clean = text.replace(/^\uFEFF/, "");
|
||||
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
|
||||
return {
|
||||
name: frontmatterField(clean, "name"),
|
||||
description: frontmatterField(clean, "description"),
|
||||
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** The name field with the folder/command it becomes. */
|
||||
function SkillNameField({
|
||||
export function SkillNameField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
@@ -175,7 +187,7 @@ function SkillNameField({
|
||||
{t("skill.name")}
|
||||
{required ? " *" : ""}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder="release-notes"
|
||||
@@ -296,7 +308,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.description")}
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
rows={2}
|
||||
value={description}
|
||||
disabled={many}
|
||||
@@ -310,7 +322,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={title}
|
||||
disabled={many}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -362,7 +374,6 @@ export function SkillAddPanel({
|
||||
dragging ? "border-accent bg-accent/10" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
@@ -370,14 +381,14 @@ export function SkillAddPanel({
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
|
||||
{t("skill.pickFiles")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => folderInput.current?.click()}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
|
||||
{t("skill.pickFolder")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -452,7 +463,10 @@ export function SkillAddPanel({
|
||||
/>
|
||||
)}
|
||||
{meta}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -489,7 +503,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.description")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
rows={2}
|
||||
value={description}
|
||||
placeholder={t("skill.descriptionHint")}
|
||||
@@ -499,7 +513,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.content")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5 font-mono text-xs`}
|
||||
className={`${field} mt-1 font-mono text-xs`}
|
||||
rows={10}
|
||||
value={content}
|
||||
placeholder={t("skill.contentHint")}
|
||||
@@ -509,12 +523,15 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -534,7 +551,7 @@ export function SkillAddPanel({
|
||||
{mode === "existing" && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={field}
|
||||
className="select w-full"
|
||||
aria-label={t("form.workspace")}
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
@@ -551,7 +568,6 @@ export function SkillAddPanel({
|
||||
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||||
{skills.map((s) => (
|
||||
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||||
@@ -696,7 +712,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
>
|
||||
<label className={label}>
|
||||
{t("plugin.link")}
|
||||
<div className="mt-0.5 flex gap-2">
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
value={link}
|
||||
@@ -728,10 +744,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
{info && (
|
||||
<div className="space-y-2" data-testid="market-plugins">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
|
||||
{info.name}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-fg-primary">{info.name}</p>
|
||||
{info.description && <p className={hint}>{info.description}</p>}
|
||||
</div>
|
||||
{info.plugins.length === 0 ? (
|
||||
@@ -777,7 +790,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
@@ -853,7 +874,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -902,7 +926,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
<input
|
||||
type="file"
|
||||
data-testid="file-upload"
|
||||
className="mt-0.5 block w-full text-xs text-fg-secondary"
|
||||
className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
@@ -920,7 +944,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
className="btn-primary shrink-0 text-sm"
|
||||
disabled={busy || !file || tooBig}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
|
||||
{t("file.upload")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* already-fetched data as props (path/data/error) rather than fetching
|
||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
||||
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
|
||||
* (`MarkdownPreview`); Raw is the default.
|
||||
* (`MarkdownPreview`), opening as Settings → Appearance says
|
||||
* (`lib/markdownView.ts`: Preview by default).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,6 +16,7 @@ import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
|
||||
import type { CcFileResponse } from "../../lib/api";
|
||||
import { CopyButton } from "./configPrimitives";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
|
||||
export function FileViewer({
|
||||
state,
|
||||
@@ -24,7 +26,7 @@ export function FileViewer({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [preview, setPreview] = useState(readMarkdownPreview);
|
||||
const canPreview = isMarkdown(state.path) && !!state.data;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file AgentDetails.tsx
|
||||
* @description What the chat shows of a subagent, like Claude Desktop: an
|
||||
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
|
||||
* the task it was given and the report it returned — read off the call, its
|
||||
* result (`tool_result.agent`) and, for a background agent, the
|
||||
* `<task-notification>` that closed it — and, one click further, the
|
||||
* subagent's own conversation as a small chat right there in the step, every
|
||||
* step of it. The chat's owner (ConversationView) supplies the links through
|
||||
* `AgentContext`: which agent transcript a call launched, the notifications
|
||||
* seen, and how to render one agent's conversation (a nested
|
||||
* ConversationView — passed in, so this module never imports it back).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
|
||||
import { fmt, formatModelName, formatMs } from "../../lib/format";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
export type AgentStatus = "running" | "done" | "error" | "stopped";
|
||||
|
||||
/** One subagent as the chat knows it from the call that launched it. */
|
||||
export interface AgentRef {
|
||||
/** The Agent/Task `tool_use` id. */
|
||||
toolUseId: string;
|
||||
/** Its own transcript's `agent_id`; null while nothing names it yet. */
|
||||
agentId: string | null;
|
||||
type: string;
|
||||
description: string;
|
||||
model: string | null;
|
||||
status: AgentStatus;
|
||||
background: boolean;
|
||||
prompt: string | null;
|
||||
report: string | null;
|
||||
durationMs: number | null;
|
||||
tokens: number | null;
|
||||
toolUses: number | null;
|
||||
}
|
||||
|
||||
/** What a `<task-notification>` said about a background agent. */
|
||||
export interface AgentNotice {
|
||||
status: string;
|
||||
result: string | null;
|
||||
durationMs: number | null;
|
||||
tokens: number | null;
|
||||
toolUses: number | null;
|
||||
}
|
||||
|
||||
export interface AgentLinks {
|
||||
/** tool_use id → agent transcript id, from the session's transcript list. */
|
||||
ids: Map<string, string>;
|
||||
/** tool_use id → the notification that closed that background agent. */
|
||||
notices: Map<string, AgentNotice>;
|
||||
/** The session runs in a pane: a call with no result yet still works. */
|
||||
live: boolean;
|
||||
/** One agent's own conversation, as a chat that fills its box. */
|
||||
render?: (agent: AgentRef) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const AgentContext = createContext<AgentLinks>({
|
||||
ids: new Map(),
|
||||
notices: new Map(),
|
||||
live: false,
|
||||
});
|
||||
|
||||
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||
export const isAgentCall = (b: TranscriptContent) =>
|
||||
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
|
||||
|
||||
// The result names the agent's own transcript (`agent-<id>.jsonl`).
|
||||
const AGENT_ID = /agentId:\s*([\w-]+)/;
|
||||
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
|
||||
|
||||
const tag = (text: string, name: string) =>
|
||||
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
|
||||
|
||||
/** The time, tokens and tool calls of a `<usage>` block, in either form
|
||||
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
|
||||
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
|
||||
const body = text.match(USAGE)?.[1] ?? "";
|
||||
const num = (...names: string[]) => {
|
||||
for (const n of names) {
|
||||
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
|
||||
if (v) return Number(v);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
durationMs: num("duration_ms"),
|
||||
tokens: num("subagent_tokens", "total_tokens"),
|
||||
toolUses: num("tool_uses"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Each `<task-notification>` in the transcript, by the call it closes. */
|
||||
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
|
||||
const out = new Map<string, AgentNotice>();
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== "user") continue;
|
||||
for (const c of msg.content) {
|
||||
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
|
||||
for (const part of c.text.split("<task-notification>").slice(1)) {
|
||||
const id = tag(part, "tool-use-id");
|
||||
if (!id) continue;
|
||||
out.set(id, {
|
||||
status: tag(part, "status") ?? "completed",
|
||||
result: tag(part, "result"),
|
||||
...parseUsage(part),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A report without the footer Claude Code appends for the orchestrator. */
|
||||
function stripFooter(text: string): string {
|
||||
return text
|
||||
.replace(USAGE, "")
|
||||
.replace(/^agentId:.*$/m, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Everything the chat can say about the subagent `block` launched. */
|
||||
export function agentRef(
|
||||
block: TranscriptContent,
|
||||
result: TranscriptContent | null | undefined,
|
||||
links: AgentLinks
|
||||
): AgentRef {
|
||||
const raw = block.input as Record<string, unknown> | undefined;
|
||||
const input = raw && !("_truncated" in raw) ? raw : {};
|
||||
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||
const id = block.id ?? "";
|
||||
const facts = result?.agent;
|
||||
const output = result?.output ?? "";
|
||||
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
|
||||
const notice = background ? links.notices.get(id) : undefined;
|
||||
const usage = !background && output ? parseUsage(output) : null;
|
||||
|
||||
let status: AgentStatus;
|
||||
if (result?.is_error) status = "error";
|
||||
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
|
||||
else if (notice && notice.status !== "completed")
|
||||
status = notice.status === "failed" ? "error" : "stopped";
|
||||
else status = "done";
|
||||
|
||||
return {
|
||||
toolUseId: id,
|
||||
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
|
||||
type: str(input.subagent_type) ?? "Agent",
|
||||
description: str(input.description) ?? "",
|
||||
model: facts?.model ?? str(input.model),
|
||||
status,
|
||||
background,
|
||||
prompt: str(input.prompt),
|
||||
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
|
||||
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
|
||||
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
|
||||
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
|
||||
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
|
||||
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
|
||||
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
|
||||
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
|
||||
};
|
||||
|
||||
/** One muted line: its status as an icon, then type · model · time ·
|
||||
* tokens · tool calls, as far as they are known. */
|
||||
export function AgentFacts({ agent }: { agent: AgentRef }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const parts = [
|
||||
agent.type,
|
||||
agent.model && formatModelName(agent.model),
|
||||
agent.background && t("conversation.agent.background"),
|
||||
agent.durationMs != null && formatMs(agent.durationMs),
|
||||
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
|
||||
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-facts"
|
||||
title={t(`conversation.agent.status.${agent.status}`)}
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
|
||||
>
|
||||
{STATUS_ICON[agent.status]}
|
||||
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
|
||||
<span className="truncate">{parts.join(" · ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
|
||||
|
||||
/** The subagent's own conversation, a thread hung under its step by a line
|
||||
* on the left — as tall as it is, up to 24rem, then it scrolls. */
|
||||
export function AgentChatBox({ agent }: { agent: AgentRef }) {
|
||||
const links = useContext(AgentContext);
|
||||
return (
|
||||
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
|
||||
{links.render?.(agent)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An Agent/Task step opened in the chat: one line of facts, its report as
|
||||
* prose, and "View conversation ›", which puts the agent's own thread (its
|
||||
* task, every step, its reply) in the report's place. With no transcript
|
||||
* to show, the task it was given opens on its own instead. */
|
||||
export function AgentStepBody({
|
||||
block,
|
||||
result,
|
||||
}: {
|
||||
block: TranscriptContent;
|
||||
result: TranscriptContent | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const links = useContext(AgentContext);
|
||||
const agent = agentRef(block, result, links);
|
||||
const canOpen = !!agent.agentId && !!links.render;
|
||||
const [thread, setThread] = useState(false);
|
||||
const [task, setTask] = useState(false);
|
||||
return (
|
||||
<div data-testid="agent-step" className="space-y-1.5">
|
||||
<AgentFacts agent={agent} />
|
||||
{thread ? (
|
||||
<AgentChatBox agent={agent} />
|
||||
) : (
|
||||
<>
|
||||
{!canOpen && agent.prompt && task && (
|
||||
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
|
||||
<MarkdownContent text={agent.prompt} dense />
|
||||
</div>
|
||||
)}
|
||||
{agent.report && (
|
||||
<div
|
||||
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
|
||||
>
|
||||
<MarkdownContent text={agent.report} dense />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="agent-open"
|
||||
aria-expanded={thread}
|
||||
onClick={() => setThread(!thread)}
|
||||
className={linkClass}
|
||||
>
|
||||
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
|
||||
</button>
|
||||
) : (
|
||||
agent.prompt && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={task}
|
||||
onClick={() => setTask(!task)}
|
||||
className={linkClass}
|
||||
>
|
||||
{t("conversation.agent.prompt")}
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,9 @@
|
||||
* A caller that knows the session has ended passes `ended`: nothing more is
|
||||
* being written, so the catch-up poll drops from 3 s to 60 s and the
|
||||
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
|
||||
* the view being shown again still fetch the tail at once.
|
||||
* the view being shown again still fetch the tail at once. The main
|
||||
* transcript hands MessageList its `sessionId`, so the list can read its
|
||||
* background agents' own transcripts for their progress.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -71,6 +73,7 @@ import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
@@ -93,6 +96,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
// at most this often: the first event still fetches at once, the last is never
|
||||
// lost, and a long transcript is not re-read for every single frame.
|
||||
const EVENT_FETCH_MS = 1500;
|
||||
// The server saw this session's transcript grow (`transcript_changed`, itself
|
||||
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
|
||||
const PUSH_FETCH_MS = 300;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
@@ -100,8 +106,15 @@ 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;
|
||||
/** A subagent's conversation inside its step: no toolbar, as tall as its
|
||||
* content up to 24rem, then it scrolls. */
|
||||
compact?: boolean;
|
||||
/** Opens a file named in a turn's "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact card's file in the chat's artifact panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The messages on screen (for an artifact's versions), as they change. */
|
||||
onMessages?: (messages: TranscriptMessage[]) => 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. */
|
||||
@@ -125,7 +138,10 @@ function ConversationViewPane({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
compact = false,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
onMessages,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
@@ -137,19 +153,21 @@ function ConversationViewPane({
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const shown = messages;
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => onMessages?.(shown), [shown, onMessages]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
for (let i = shown.length - 1; i >= 0; i--) {
|
||||
const m = shown[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
}, [shown, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -184,9 +202,31 @@ function ConversationViewPane({
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
|
||||
// A subagent's own conversation opens inside its step as a small chat: this
|
||||
// same view, on that agent's transcript.
|
||||
const renderAgent = useCallback(
|
||||
(a: AgentRef) => (
|
||||
<ConversationView
|
||||
key={a.agentId}
|
||||
sessionId={sessionId}
|
||||
initialTranscriptId={a.agentId}
|
||||
compact
|
||||
live={live && a.status === "running"}
|
||||
ended={a.status !== "running"}
|
||||
/>
|
||||
),
|
||||
[sessionId, live]
|
||||
);
|
||||
const agentLinks = useMemo<AgentLinks>(() => {
|
||||
const ids = new Map<string, string>();
|
||||
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
|
||||
return { ids, notices: agentNotices(shown), live, render: renderAgent };
|
||||
}, [transcripts, shown, live, renderAgent]);
|
||||
|
||||
// Load available transcript list (also rescanned on a short interval so
|
||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
async function loadTranscripts() {
|
||||
try {
|
||||
@@ -226,6 +266,12 @@ function ConversationViewPane({
|
||||
firstLineRef.current = 0;
|
||||
|
||||
async function load() {
|
||||
// No session yet: no history.
|
||||
if (!sessionId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
@@ -269,6 +315,7 @@ function ConversationViewPane({
|
||||
// parsed message we've seen. The server already de-overlaps via
|
||||
// afterLine, so we can safely append.
|
||||
const fetchNewMessages = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
if (fetchingRef.current) {
|
||||
// Coalesce: remember a trigger arrived during this fetch and re-run
|
||||
// exactly once when the in-flight request settles.
|
||||
@@ -330,7 +377,12 @@ function ConversationViewPane({
|
||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
const fetchPushed = useMemo(
|
||||
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||
@@ -339,13 +391,14 @@ function ConversationViewPane({
|
||||
fetchSoon();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchSoon();
|
||||
if (msg.type === "transcript_changed") fetchPushed();
|
||||
else fetchSoon();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchSoon, onScreen]);
|
||||
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
|
||||
|
||||
onShowRef.current = () => {
|
||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||
@@ -507,7 +560,7 @@ function ConversationViewPane({
|
||||
|
||||
// Auto-scroll to bottom after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
if (!loading && shown.length > 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -516,7 +569,7 @@ function ConversationViewPane({
|
||||
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||
{/* Toolbar - always rendered after the initial load so users can
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && !fill && (
|
||||
{!loading && !fill && !compact && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
@@ -564,7 +617,13 @@ function ConversationViewPane({
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
style={
|
||||
compact
|
||||
? { maxHeight: "24rem" }
|
||||
: fill
|
||||
? { minHeight: 0 }
|
||||
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
|
||||
}
|
||||
>
|
||||
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||
(the outline's file button floats there). */}
|
||||
@@ -587,10 +646,12 @@ function ConversationViewPane({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
<div
|
||||
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
|
||||
>
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
) : shown.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -606,7 +667,7 @@ function ConversationViewPane({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
) : shown.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">
|
||||
@@ -615,13 +676,19 @@ function ConversationViewPane({
|
||||
</div>
|
||||
) : (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
messages={shown}
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
live={live}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
stopped={stopped}
|
||||
agentLinks={agentLinks}
|
||||
compact={compact}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -648,7 +715,7 @@ function ConversationViewPane({
|
||||
)}
|
||||
|
||||
{/* New messages indicator */}
|
||||
{showNewMsg && !fill && (
|
||||
{showNewMsg && !fill && !compact && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||
* when the page provides one (the page resolves a relative one against its own
|
||||
@@ -272,8 +272,14 @@ const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?
|
||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||
const REL_PATH_RE =
|
||||
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||
// A Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
|
||||
const WIN_PATH_RE =
|
||||
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
|
||||
// In inline code the whole text is the path, so a Windows one may hold spaces.
|
||||
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
|
||||
/** A path at the start of `text`, absolute or relative, or null. */
|
||||
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
const matchPath = (text: string) =>
|
||||
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||
// A markdown link target that is a page, not a file.
|
||||
@@ -393,7 +399,13 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
|
||||
push(
|
||||
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file AgentDetails.test.tsx
|
||||
* @description Verifies what the chat shows of a subagent: an Agent step's
|
||||
* facts, task and report (from its result, or the task-notification that
|
||||
* closed a background one), and that "View conversation" opens that
|
||||
* agent's own conversation in place, rendered by the chat's owner.
|
||||
* @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 { MessageList } from "../MessageList";
|
||||
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
|
||||
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||
|
||||
const call: TranscriptContent = {
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
|
||||
};
|
||||
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
|
||||
ids: new Map(),
|
||||
notices: new Map(),
|
||||
live: false,
|
||||
...p,
|
||||
});
|
||||
|
||||
describe("agentRef", () => {
|
||||
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
|
||||
const a = agentRef(
|
||||
call,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
|
||||
agent: {
|
||||
id: "a1b2",
|
||||
model: "claude-opus-5-5",
|
||||
status: "completed",
|
||||
duration_ms: 65_000,
|
||||
tokens: 12_000,
|
||||
tool_uses: 7,
|
||||
},
|
||||
},
|
||||
links()
|
||||
);
|
||||
expect(a).toMatchObject({
|
||||
agentId: "a1b2",
|
||||
type: "Explore",
|
||||
status: "done",
|
||||
background: false,
|
||||
prompt: "Read **server/**",
|
||||
report: "The hooks live in server/routes/hooks.js.",
|
||||
durationMs: 65_000,
|
||||
tokens: 12_000,
|
||||
toolUses: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes a background agent with its notification's report and usage", () => {
|
||||
const notice = m({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
|
||||
},
|
||||
],
|
||||
});
|
||||
const launched: TranscriptContent = {
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "Async agent launched successfully.\nagentId: a9b4",
|
||||
};
|
||||
const running = agentRef(call, launched, links({ live: true }));
|
||||
expect(running).toMatchObject({ status: "running", background: true, report: null });
|
||||
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
|
||||
expect(done).toMatchObject({
|
||||
agentId: "a9b4",
|
||||
status: "done",
|
||||
report: "All done",
|
||||
tokens: 88007,
|
||||
toolUses: 31,
|
||||
durationMs: 132350,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds the transcript from the session's list when the result names none", () => {
|
||||
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
|
||||
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
|
||||
});
|
||||
|
||||
it("parses the line form of a usage block", () => {
|
||||
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
|
||||
tokens: 500,
|
||||
toolUses: 3,
|
||||
durationMs: 900,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("an Agent step in the chat", () => {
|
||||
it("shows the agent's facts and report, and opens its conversation in place", () => {
|
||||
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
agentLinks={links({ render: render_ })}
|
||||
messages={[
|
||||
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
|
||||
m({
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "Found **3** hooks",
|
||||
agent: {
|
||||
id: "a1b2",
|
||||
model: null,
|
||||
status: "completed",
|
||||
duration_ms: null,
|
||||
tokens: null,
|
||||
tool_uses: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
|
||||
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
|
||||
expect(screen.queryByTestId("agent-chat")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("agent-open"));
|
||||
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
|
||||
// The thread takes the report's place.
|
||||
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file ConversationView.pushed.test.tsx
|
||||
* @description The server says a session's transcript grew
|
||||
* (`transcript_changed`): the chat showing that session reads the new lines
|
||||
* at once, and again within a short window after a burst — not at the hook
|
||||
* throttle's 1.5 s pace — while another session's notice fetches nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
import { api } from "../../../lib/api";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-21T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const pushed = (session_id: string) =>
|
||||
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
|
||||
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||
|
||||
describe("ConversationView on transcript_changed", () => {
|
||||
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
vi.useFakeTimers();
|
||||
const before = calls();
|
||||
pushed("s1");
|
||||
expect(calls()).toBe(before + 1);
|
||||
pushed("s1");
|
||||
pushed("s1");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(calls()).toBe(before + 2);
|
||||
});
|
||||
|
||||
it("ignores another session's transcript", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const before = calls();
|
||||
pushed("s2");
|
||||
await act(async () => {});
|
||||
expect(calls()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,20 @@ describe("file links", () => {
|
||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||
});
|
||||
|
||||
it("opens a Windows path, bare or in inline code", () => {
|
||||
const open = withOpen(
|
||||
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
|
||||
expect(open.mock.calls).toEqual([
|
||||
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
|
||||
["C:/src/a.ts"],
|
||||
["D:\\My Docs\\b.md"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a markdown link to a file instead of navigating", () => {
|
||||
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @file MessageList.agents.test.tsx
|
||||
* @description Verifies a live chat's background-agents card reads each
|
||||
* running agent's own transcript (by the agentId its launch result names)
|
||||
* and shows the step it is on and its context size (issue #81).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageList, agentProgress } from "../MessageList";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
|
||||
});
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||
|
||||
// The subagent's own transcript: its task, then a search still running.
|
||||
const tail: TranscriptMessage[] = [
|
||||
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "tool_use",
|
||||
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "g1",
|
||||
name: "Grep",
|
||||
input: { pattern: "copyId", description: "Searching for copyId" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const main: TranscriptMessage[] = [
|
||||
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
m({
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: JSON.stringify([
|
||||
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
|
||||
]),
|
||||
},
|
||||
],
|
||||
}),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "waiting" }],
|
||||
}),
|
||||
];
|
||||
|
||||
describe("background agents' progress", () => {
|
||||
const rects = Element.prototype.getClientRects;
|
||||
beforeEach(() => {
|
||||
// jsdom lays nothing out: make the card count as on screen.
|
||||
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
|
||||
vi.mocked(api.sessions.transcript).mockResolvedValue({
|
||||
messages: tail,
|
||||
total: 2,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 2,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
Element.prototype.getClientRects = rects;
|
||||
vi.mocked(api.sessions.transcript).mockReset();
|
||||
});
|
||||
|
||||
it("reads the step an agent is on and its context", () => {
|
||||
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
|
||||
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
|
||||
});
|
||||
|
||||
it("shows them on the agent's line, from its own transcript", async () => {
|
||||
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
|
||||
"Searching for copyId"
|
||||
)
|
||||
);
|
||||
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
|
||||
agent_id: "a4118367b503adecc",
|
||||
limit: 20,
|
||||
});
|
||||
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
|
||||
});
|
||||
|
||||
it("reads nothing without the session", () => {
|
||||
render(<MessageList live loading={false} messages={main} />);
|
||||
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
|
||||
expect(api.sessions.transcript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 3, removed: 1 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 10, removed: 0 }),
|
||||
call("3", "Edit", "/w/a.ts"),
|
||||
result("3", { added: 2, removed: 2 }),
|
||||
@@ -65,8 +65,35 @@ describe("the Edited N files card", () => {
|
||||
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");
|
||||
fireEvent.click(within(card).getByTitle("/w/b.json"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
|
||||
});
|
||||
|
||||
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
const onOpenArtifact = vi.fn();
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
messages={[
|
||||
user("make a page"),
|
||||
call("1", "Write", "/w/page.html"),
|
||||
result("1", { added: 20, removed: 0 }),
|
||||
call("2", "Edit", "/w/App.tsx"),
|
||||
result("2", { added: 1, removed: 1 }),
|
||||
user("thanks"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const cards = screen.getAllByTestId("artifact-card");
|
||||
// A .tsx it only edited is project code, not an artifact.
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]!.textContent).toContain("page");
|
||||
fireEvent.click(cards[0]!);
|
||||
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
|
||||
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||
@@ -77,7 +104,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 2, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
@@ -90,14 +117,14 @@ describe("the Edited N files card", () => {
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||
const one = drag(within(card).getByTitle("/w/b.json"));
|
||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||
{ path: "@/w/b.md", name: "b.md" },
|
||||
{ path: "@/w/b.json", name: "b.json" },
|
||||
]);
|
||||
expect(one["text/plain"]).toBe("/w/b.md");
|
||||
expect(one["text/plain"]).toBe("/w/b.json");
|
||||
const all = drag(within(card).getByText("Edited 2 files"));
|
||||
expect(
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||
* shows as a chip.
|
||||
* shows as a chip. A live chat lists the background subagents still running
|
||||
* and does not call their turn done (issue #81).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -137,7 +138,28 @@ describe("MessageList — sender attribution", () => {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/↓ 123/)).toBeTruthy();
|
||||
// In the hover bar's tooltip, beside the model and time.
|
||||
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
|
||||
});
|
||||
|
||||
it("shows a local command as one line with its output, never its caveat", () => {
|
||||
const line = (text: string, sender: "user" | "system" = "user") =>
|
||||
msg({ type: "user", sender, content: [{ type: "text", text }] });
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
|
||||
line("<command-name>/model</command-name><command-args>opus</command-args>"),
|
||||
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId("chat-command");
|
||||
expect(row).toHaveTextContent("/model opus");
|
||||
expect(row).toHaveTextContent("Set model to Opus");
|
||||
expect(screen.queryByText(/Caveat/)).toBeNull();
|
||||
expect(screen.queryByTestId("chat-user")).toBeNull();
|
||||
});
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
@@ -167,7 +189,9 @@ describe("MessageList — sender attribution", () => {
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Run the full client suite");
|
||||
// Like the terminal: the turn's time since the prompt, and its output.
|
||||
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
||||
// (the output shows on hover).
|
||||
expect(row).toHaveTextContent(/1m \ds/);
|
||||
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
|
||||
// Its result arrived: nothing is running any more.
|
||||
rerender(
|
||||
<MessageList
|
||||
@@ -340,7 +364,88 @@ describe("MessageList — sender attribution", () => {
|
||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||
});
|
||||
expect(state([prompt, call, result, stopped])).toBeNull();
|
||||
// A local command (`/clear`) is answered by the CLI itself: no turn.
|
||||
const local = (text: string) =>
|
||||
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
|
||||
const clear = local("<command-name>/clear</command-name>");
|
||||
const out = local("<local-command-stdout></local-command-stdout>");
|
||||
expect(state([clear, out])).toBeNull();
|
||||
// A prompt command (a skill) is still answered by the model.
|
||||
expect(state([clear])).toEqual(thinking);
|
||||
// Silent for 15 minutes with nothing running: the turn died.
|
||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
describe("background subagents", () => {
|
||||
const at = new Date().toISOString();
|
||||
const launch = msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const launched = msg({
|
||||
timestamp: at,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
|
||||
},
|
||||
],
|
||||
});
|
||||
const reply = msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "waiting for the agent" }],
|
||||
});
|
||||
const notified = msg({
|
||||
sender: "system",
|
||||
timestamp: at,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
|
||||
},
|
||||
],
|
||||
});
|
||||
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
|
||||
|
||||
it("lists an agent until its notification names the call", () => {
|
||||
const results = (m: TranscriptMessage[]) =>
|
||||
new Map(
|
||||
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
|
||||
);
|
||||
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
|
||||
const all = [...base, notified];
|
||||
expect(backgroundAgents(all, results(all))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not call the turn done while the agent works", () => {
|
||||
const { rerender } = render(<MessageList live loading={false} messages={base} />);
|
||||
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
|
||||
"ExploreChecking copyId coverage"
|
||||
);
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
|
||||
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
|
||||
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
|
||||
});
|
||||
|
||||
it("shows nothing once the session is no longer live", () => {
|
||||
render(<MessageList loading={false} messages={base} />);
|
||||
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
// ───────────────────────── Copy button ─────────────────────────
|
||||
|
||||
@@ -111,13 +112,9 @@ export function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API can fail in insecure contexts - silently ignore.
|
||||
}
|
||||
if (!(await copyText(text, true))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git). There is no AI tool picker: the lane is created with the
|
||||
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
|
||||
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
@@ -30,6 +31,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -116,6 +118,9 @@ export function AddLaneModal({
|
||||
// server asked for one — a login for the URL's https host.
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [clonePartial, setClonePartial] = useState(false);
|
||||
/** The running clone's progress token (CloneProgress polls it). */
|
||||
const [cloneToken, setCloneToken] = useState<string | null>(null);
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
@@ -159,6 +164,7 @@ export function AddLaneModal({
|
||||
setMode("repo");
|
||||
setCloneUrl("");
|
||||
setCloneName("");
|
||||
setClonePartial(false);
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setSourceRepo("");
|
||||
@@ -236,13 +242,17 @@ export function AddLaneModal({
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
const progressToken = newCloneToken();
|
||||
setCloneToken(progressToken);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
progressToken,
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
@@ -274,6 +284,8 @@ export function AddLaneModal({
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
} finally {
|
||||
setCloneToken(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -547,6 +559,23 @@ export function AddLaneModal({
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<label
|
||||
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||
title={t("clone.partialHint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="add-lane-clone-partial"
|
||||
checked={clonePartial}
|
||||
onChange={(e) => setClonePartial(e.target.checked)}
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{cloneToken && (
|
||||
<div className="mt-2">
|
||||
<CloneProgress token={cloneToken} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @file CloneHereDialog.tsx
|
||||
* @description The Files page's **New → Clone repository**: clones a URL into
|
||||
* the folder the File Explorer has open, through the same
|
||||
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
|
||||
* to its allowlist and the folder to the user's own, and clones as the
|
||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
export function CloneHereDialog({
|
||||
open,
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
const [url, setUrl] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
if (!open) return null;
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onCloned(result.lane, result.path);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
token: "",
|
||||
remember: l?.remember ?? true,
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={busy ? undefined : onClose}
|
||||
// A portal still bubbles through React: keep keys away from the File
|
||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid="fx-clone-dialog"
|
||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
{t("fx.cloneRepo")}
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
|
||||
{t("clone.urlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id={`${titleId}-url`}
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://host/owner/repo.git"
|
||||
disabled={busy}
|
||||
className="input w-full font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
|
||||
{t("clone.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id={`${titleId}-name`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={cloneDirName(url)}
|
||||
disabled={busy}
|
||||
className="input w-full font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 break-all text-[11px] text-fg-muted">
|
||||
{t("clone.parentHint", { path: into })}
|
||||
</p>
|
||||
</div>
|
||||
{login && (
|
||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
disabled={busy}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<input
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
disabled={busy}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
disabled={busy}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
{sshRefused && (
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||
{t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !url.trim()}
|
||||
className="btn-primary text-xs"
|
||||
data-testid="fx-clone-submit"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file CloneProgress — the live line under a running clone: its place in the
|
||||
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
|
||||
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
|
||||
* yet arrived, or an older server — just shows nothing yet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type CloneProgressView } from "../../lib/api";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
|
||||
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
|
||||
export function newCloneToken(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function CloneProgress({ token }: { token: string }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [view, setView] = useState<CloneProgressView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setView(null);
|
||||
// Never throws: progress is a courtesy, the clone's own answer decides.
|
||||
const poll = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.cloneProgress(token))
|
||||
.then((v) => {
|
||||
if (alive) setView(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!view || view.state === "done" || view.state === "failed") return null;
|
||||
|
||||
if (view.state === "queued") {
|
||||
return (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const phase = view.phase
|
||||
? view.phase === "other"
|
||||
? view.label || ""
|
||||
: t(`clone.phase.${view.phase}`)
|
||||
: t("clone.connecting");
|
||||
const percent = view.percent ?? 0;
|
||||
return (
|
||||
<div data-testid="clone-progress" className="space-y-1">
|
||||
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
|
||||
<span className="truncate">
|
||||
{phase}
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
<div
|
||||
className="h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={phase}
|
||||
>
|
||||
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,62 @@
|
||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||
* added as an extra folder, one after another. A host that wants a login
|
||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||
* URLs with it, like Add workspace → Clone.
|
||||
* URLs with it, like Add workspace → Clone. Before anything runs, each URL
|
||||
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
|
||||
* link while the account has no SSH key is not cloned at all — the user is
|
||||
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||
* the login row up front (optional: a public repo needs none). The identity is
|
||||
* read again when the window regains focus, so a key made in another tab
|
||||
* counts at once. The running clone shows its queue place, then git's
|
||||
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
|
||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||
type Row = {
|
||||
url: string;
|
||||
state: "wait" | "busy" | "done" | "failed";
|
||||
note?: string;
|
||||
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||
ssh?: boolean;
|
||||
/** While busy: the progress token CloneProgress polls. */
|
||||
token?: string;
|
||||
};
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
|
||||
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
|
||||
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
|
||||
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
|
||||
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
|
||||
} catch {
|
||||
/* not a URL yet — the server's allowlist answers that one */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
|
||||
function missingFor(
|
||||
url: string,
|
||||
id: GitIdentity | null
|
||||
): { kind: "ssh" | "https"; host: string } | null {
|
||||
const target = cloneTarget(url);
|
||||
if (!id || !target) return null;
|
||||
if (target.kind === "ssh") {
|
||||
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
|
||||
return keys > 0 ? null : target;
|
||||
}
|
||||
return id.https.some((h) => h.host === target.host) ? null : target;
|
||||
}
|
||||
|
||||
export function CloneSourcesForm({
|
||||
laneId,
|
||||
parent,
|
||||
@@ -31,6 +75,25 @@ export function CloneSourcesForm({
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
const [partial, setPartial] = useState(false);
|
||||
const [identity, setIdentity] = useState<GitIdentity | null>(null);
|
||||
|
||||
// An unknown identity (an older server, a failed read) checks nothing: the
|
||||
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
|
||||
const loadIdentity = useCallback(
|
||||
() =>
|
||||
Promise.resolve()
|
||||
.then(() => api.gitIdentity.get())
|
||||
.then(setIdentity)
|
||||
.catch(() => {}),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadIdentity();
|
||||
const onFocus = () => void loadIdentity();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [loadIdentity]);
|
||||
|
||||
const urls = (s: string) => [
|
||||
...new Set(
|
||||
@@ -44,16 +107,27 @@ export function CloneSourcesForm({
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
setBusy(true);
|
||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||
// An SSH link with no key on the account would only fail at the host:
|
||||
// it is not tried, and its row says what to set up first.
|
||||
const next: Row[] = list.map((url) => {
|
||||
const miss = missingFor(url, identity);
|
||||
return miss?.kind === "ssh"
|
||||
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
|
||||
: { url, state: "wait" };
|
||||
});
|
||||
const show = () => setRows([...next]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
row.token = newCloneToken();
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
progressToken: row.token,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
@@ -80,10 +154,12 @@ export function CloneSourcesForm({
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
row.ssh = true;
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
row.token = undefined;
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
@@ -99,6 +175,18 @@ export function CloneSourcesForm({
|
||||
|
||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||
|
||||
// Checked as the user types, so a missing key is said before Clone is pressed.
|
||||
const missing = urls(text)
|
||||
.map((u) => missingFor(u, identity))
|
||||
.filter((m): m is NonNullable<typeof m> => m !== null);
|
||||
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
|
||||
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
|
||||
const settingsLink = (
|
||||
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||
@@ -112,6 +200,41 @@ export function CloneSourcesForm({
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||
title={t("clone.partialHint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="workspace-clone-partial"
|
||||
checked={partial}
|
||||
onChange={(e) => setPartial(e.target.checked)}
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{sshHosts.length > 0 && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="workspace-clone-need-ssh"
|
||||
className="text-[11px] text-status-danger"
|
||||
>
|
||||
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
|
||||
</p>
|
||||
)}
|
||||
{httpsHosts.length > 0 && !login && (
|
||||
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
|
||||
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
|
||||
}
|
||||
>
|
||||
{t("home.cloneEnterLogin")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{login && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
@@ -178,7 +301,17 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
{r.state === "busy" && r.token && (
|
||||
<div className="ml-[18px] mt-0.5">
|
||||
<CloneProgress token={r.token} />
|
||||
</div>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
{r.note}
|
||||
{r.ssh && <> {settingsLink}</>}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
* without waiting the 7 days.
|
||||
* The sidebar nests each workspace under its owner's home (a chevron drops
|
||||
* them down, remembered per browser); one with no owner home stays in a flat
|
||||
* Workspaces list.
|
||||
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||
* (server/lib/file-explorer.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -91,12 +94,58 @@ import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
/** One upload batch at most (the server caps one file the same). */
|
||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||
/** Files up to this size travel together, many per request (`uploadBatch`):
|
||||
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
|
||||
const SMALL_FILE = 4 * 1024 * 1024;
|
||||
const PACK_BYTES = 32 * 1024 * 1024;
|
||||
const PACK_FILES = 1000;
|
||||
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
|
||||
const PACK_NAME_BYTES = 400 * 1024;
|
||||
|
||||
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
|
||||
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
||||
const packs: T[][] = [];
|
||||
const singles: T[] = [];
|
||||
let cur: T[] = [];
|
||||
let bytes = 0;
|
||||
let names = 0;
|
||||
for (const f of files) {
|
||||
if (f.file.size > SMALL_FILE) {
|
||||
singles.push(f);
|
||||
continue;
|
||||
}
|
||||
const n = f.rel.length * 3 + 64;
|
||||
if (
|
||||
cur.length &&
|
||||
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
|
||||
) {
|
||||
packs.push(cur);
|
||||
cur = [];
|
||||
bytes = 0;
|
||||
names = 0;
|
||||
}
|
||||
cur.push(f);
|
||||
bytes += f.file.size;
|
||||
names += n;
|
||||
}
|
||||
if (cur.length) packs.push(cur);
|
||||
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
|
||||
return { packs: packs.filter((p) => p.length >= 2), singles };
|
||||
}
|
||||
const MIME = "application/x-agenthub-fx";
|
||||
const VIEW_KEY = "agenthub.files.view";
|
||||
const HIDDEN_KEY = "agenthub.files.hidden";
|
||||
/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */
|
||||
const HOMES_OPEN_KEY = "agenthub.files.homesOpen";
|
||||
/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the
|
||||
* viewer may not open that home (an analyst's view of other users). */
|
||||
type UserRow = FsHome & { noHome?: boolean };
|
||||
|
||||
type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||
@@ -268,6 +317,7 @@ export function FileBrowser({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
const [askAll, setAskAll] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
@@ -278,6 +328,16 @@ export function FileBrowser({
|
||||
bytes: number;
|
||||
} | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const { state: auth } = useAuth();
|
||||
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
const [openHomes, setOpenHomes] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]'));
|
||||
return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||||
} catch {
|
||||
return new Set(["me"]);
|
||||
}
|
||||
});
|
||||
const [clip, setClip] = useState<Clip | null>(clipboard);
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
|
||||
@@ -314,6 +374,22 @@ export function FileBrowser({
|
||||
}, []);
|
||||
// Another user's home (an admin's view) is read-only.
|
||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||
// or of the user's own home (never a session worktree, an extra folder or
|
||||
// another user's home); the server checks the folder again.
|
||||
const cloneRoot = isHomePlace(target)
|
||||
? target.home === "me"
|
||||
? (homes.find((h) => h.id === "me")?.dir ?? null)
|
||||
: null
|
||||
: typeof target === "number"
|
||||
? (lanes.find((l) => l.id === target)?.cwd ?? null)
|
||||
: null;
|
||||
const cloneParent =
|
||||
cloneRoot && view === "files"
|
||||
? path
|
||||
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
|
||||
: cloneRoot
|
||||
: null;
|
||||
|
||||
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
||||
const go = (p: string, tgt: FsPlace = target) => {
|
||||
@@ -542,32 +618,95 @@ export function FileBrowser({
|
||||
}
|
||||
setBusy(true);
|
||||
let sent = 0;
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
let forAll: FsConflict | null = null;
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const { file, rel } = files[i]!;
|
||||
const dest = join(dir, rel);
|
||||
setProgress({ name: rel, done: i, total: files.length, sent, bytes });
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(target, dest, file, c, (n) =>
|
||||
setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes })
|
||||
);
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
const a = await askConflict([rel], files.length > 1);
|
||||
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
if (a.all) forAll = a.choice;
|
||||
return send(a.choice);
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") break;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) =>
|
||||
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
const a = await askConflict([rel], files.length > 1);
|
||||
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
if (a.all) forAll = a.choice;
|
||||
return send(a.choice);
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
done++;
|
||||
return true;
|
||||
};
|
||||
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
|
||||
const pack = async (b: { file: File; rel: string }[]) => {
|
||||
const label = `${b[0]!.rel} (+${b.length - 1})`;
|
||||
const size = b.reduce((n, f) => n + f.file.size, 0);
|
||||
show(label);
|
||||
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
|
||||
const choose = async (taken: string[]) => {
|
||||
if (forAll) return forAll;
|
||||
if (!taken.length) return "fail" as const;
|
||||
const a = await askConflict(taken, files.length > 1);
|
||||
if (a.all && a.choice) forAll = a.choice;
|
||||
return a.choice;
|
||||
};
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
target,
|
||||
dir,
|
||||
b.map((f) => f.rel)
|
||||
);
|
||||
if (!c.batch) {
|
||||
batchable = false;
|
||||
return "fallback";
|
||||
}
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
policy = await choose((e as { names?: string[] }).names ?? []);
|
||||
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
return send(policy);
|
||||
});
|
||||
saved += r.saved;
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
if (code === "ECANCEL") return "cancel";
|
||||
// Refused before a byte was written (a bad name, an old helper…):
|
||||
// each file then gets its own answer.
|
||||
if (code === "EUPGRADE") batchable = false;
|
||||
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
|
||||
return "fallback";
|
||||
}
|
||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||
}
|
||||
sent += size;
|
||||
done += b.length;
|
||||
return "ok";
|
||||
};
|
||||
try {
|
||||
const { packs, singles } = packUploads(files);
|
||||
let go = true;
|
||||
for (const b of packs) {
|
||||
const r = batchable ? await pack(b) : "fallback";
|
||||
if (r === "cancel") go = false;
|
||||
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
|
||||
if (!go) break;
|
||||
}
|
||||
for (const f of singles) if (!go || !(await one(f))) break;
|
||||
} finally {
|
||||
setProgress(null);
|
||||
setBusy(false);
|
||||
@@ -633,7 +772,7 @@ export function FileBrowser({
|
||||
writePref(VIEW_KEY, l);
|
||||
};
|
||||
const onKey = (e: ReactKeyboardEvent) => {
|
||||
if (renaming || creating || ask) return;
|
||||
if (renaming || creating || ask || cloning) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const k = e.key.toLowerCase();
|
||||
// Another user's home is read-only: no key that writes.
|
||||
@@ -772,6 +911,52 @@ export function FileBrowser({
|
||||
onCancel={() => setRenaming(null)}
|
||||
/>
|
||||
) : null;
|
||||
// Each workspace drops down under its owner: the caller's own under My
|
||||
// home, another user's under theirs (`owner_name` = that home's id). An
|
||||
// owner whose home this viewer may not open (an analyst's view of other
|
||||
// users) still gets a row, one that only groups. A workspace with no owner
|
||||
// stays in the flat Workspaces list. Which rows are open is per browser.
|
||||
const { lanesOfHome, userRows } = useMemo(() => {
|
||||
const ids = new Set(homes.map((h) => h.id));
|
||||
const map = new Map<string, Lane[]>();
|
||||
const extra = new Map<string, UserRow>();
|
||||
for (const l of lanes) {
|
||||
const home =
|
||||
myId != null && l.owner_id === myId && ids.has("me")
|
||||
? "me"
|
||||
: l.owner_name && l.owner_name !== "me"
|
||||
? l.owner_name
|
||||
: null;
|
||||
if (!home) continue;
|
||||
map.set(home, [...(map.get(home) ?? []), l]);
|
||||
if (!ids.has(home) && !extra.has(home)) {
|
||||
extra.set(home, {
|
||||
id: home,
|
||||
label: l.owner_display_name || home,
|
||||
readOnly: true,
|
||||
noHome: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const rows: UserRow[] = [
|
||||
...homes.filter((h) => h.id !== "me"),
|
||||
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
return { lanesOfHome: map, userRows: rows };
|
||||
}, [homes, lanes, myId]);
|
||||
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
||||
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
|
||||
// A home holding the open workspace shows it, whatever was stored.
|
||||
const homeIsOpen = (id: string, own: Lane[]) =>
|
||||
openHomes.has(id) ||
|
||||
(view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target)));
|
||||
const toggleHome = (id: string, own: Lane[]) => {
|
||||
const next = new Set(openHomes);
|
||||
if (homeIsOpen(id, own)) next.delete(id);
|
||||
else next.add(id);
|
||||
setOpenHomes(next);
|
||||
writePref(HOMES_OPEN_KEY, JSON.stringify([...next]));
|
||||
};
|
||||
const placeClass = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
|
||||
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
|
||||
@@ -784,67 +969,120 @@ export function FileBrowser({
|
||||
onClick={() => setMenu(null)}
|
||||
data-testid="file-explorer"
|
||||
>
|
||||
{cloneParent && (
|
||||
<CloneHereDialog
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
|
||||
changed();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* ── Places ── */}
|
||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||
{homes.some((h) => h.id === "me") && (
|
||||
<div className="pt-3">
|
||||
{(() => {
|
||||
const laneRow = (l: Lane, nested: boolean) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => go("", { home: "me" })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === "me"
|
||||
)}
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={`${placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}${nested ? " pl-7" : ""}`}
|
||||
>
|
||||
<House className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{t("fx.myHome")}</span>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{homes.some((h) => h.id !== "me") && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.userHomes")}
|
||||
</p>
|
||||
{homes
|
||||
.filter((h) => h.id !== "me")
|
||||
.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
title={t("fx.readOnlyHome")}
|
||||
onClick={() => go("", { home: h.id })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
>
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{h.label}</span>
|
||||
<Eye className="ml-auto h-3 w-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.places")}
|
||||
</p>
|
||||
{lanes.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
// A home row with a chevron: its user's workspaces drop down under it.
|
||||
const homeRow = (h: UserRow) => {
|
||||
const own = lanesOfHome.get(h.id) ?? [];
|
||||
const open = own.length > 0 && homeIsOpen(h.id, own);
|
||||
const mine = h.id === "me";
|
||||
// No home to open: the name drops the list down too.
|
||||
const browse = !h.noHome;
|
||||
return (
|
||||
<div key={h.id} data-testid={`fx-home-${h.id}`}>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("fx.toggleWorkspaces", {
|
||||
name: mine ? t("fx.myHome") : h.label,
|
||||
})}
|
||||
aria-expanded={open}
|
||||
disabled={!own.length}
|
||||
onClick={() => toggleHome(h.id, own)}
|
||||
className="flex h-6 w-5 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-2 disabled:opacity-0"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={mine || !browse ? undefined : t("fx.readOnlyHome")}
|
||||
aria-expanded={browse ? undefined : open}
|
||||
onClick={() => (browse ? go("", { home: h.id }) : toggleHome(h.id, own))}
|
||||
onContextMenu={browse ? (e) => placeCtx.open(e, { home: h.id }) : undefined}
|
||||
className={placeClass(
|
||||
browse && view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
>
|
||||
{mine ? (
|
||||
<House className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{mine ? t("fx.myHome") : h.label}</span>
|
||||
{own.length > 0 && (
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
{own.length}
|
||||
</span>
|
||||
)}
|
||||
{!mine && browse && (
|
||||
<Eye
|
||||
className={`${own.length ? "" : "ml-auto "}h-3 w-3 shrink-0 opacity-60`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{open && own.map((l) => laneRow(l, true))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const me = homes.find((h) => h.id === "me");
|
||||
const others = userRows;
|
||||
return (
|
||||
<>
|
||||
{me && <div className="pt-3">{homeRow(me)}</div>}
|
||||
{others.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{others.some((h) => !h.noHome) ? t("fx.userHomes") : t("fx.users")}
|
||||
</p>
|
||||
{others.map(homeRow)}
|
||||
</>
|
||||
)}
|
||||
{looseLanes.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.places")}
|
||||
</p>
|
||||
{looseLanes.map((l) => laneRow(l, false))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</nav>
|
||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||
@@ -1018,6 +1256,17 @@ export function FileBrowser({
|
||||
>
|
||||
{t("fx.newFile")}
|
||||
</DropItem>
|
||||
{cloneParent && (
|
||||
<DropItem
|
||||
icon={<FolderGit2 className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
setCloning(true);
|
||||
}}
|
||||
>
|
||||
{t("fx.cloneRepo")}
|
||||
</DropItem>
|
||||
)}
|
||||
</DropButton>
|
||||
<DropButton
|
||||
label={t("fx.upload")}
|
||||
@@ -1449,6 +1698,11 @@ export function FileBrowser({
|
||||
{t("fx.newFolder")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
||||
{cloneParent && (
|
||||
<MenuItem onSelect={close(() => setCloning(true))}>
|
||||
{t("fx.cloneRepo")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
||||
{t("fx.uploadFiles")}
|
||||
@@ -1839,7 +2093,7 @@ function InlineName({
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") onDone();
|
||||
if (isSubmitEnter(e)) onDone();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onBlur={onDone}
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
@@ -873,9 +874,10 @@ export function LaneChangesPanel({
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file.
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
useEffect(() => setMdPreview(false), [openFile]);
|
||||
// A whole markdown file shown rendered instead of as source; per file, each
|
||||
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
@@ -1061,6 +1063,9 @@ export function LaneChangesPanel({
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
// A reply for the previous ref/file/page lands after the next request
|
||||
// started: drop it, or it overwrites the list the user is looking at.
|
||||
let live = true;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, {
|
||||
@@ -1068,16 +1073,23 @@ export function LaneChangesPanel({
|
||||
...(historyRef ? { ref: historyRef } : {}),
|
||||
...(historyFile ? { file: historyFile } : {}),
|
||||
})
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||||
.then(forLane(laneId, (r) => live && setCommits(r.commits)))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
let live = true;
|
||||
void api.lanes
|
||||
.gitBranches(target)
|
||||
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
|
||||
.catch(() => setHistoryBranches([]));
|
||||
.then(forLane(laneId, (r) => live && setHistoryBranches(r.branches)))
|
||||
.catch(() => live && setHistoryBranches([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, laneId, reread]);
|
||||
|
||||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||||
@@ -1171,24 +1183,33 @@ export function LaneChangesPanel({
|
||||
// own fetch — and only while the viewer is actually showing it.
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
||||
// Opening file B before file A's read came back must not show (and let the
|
||||
// editor save) A's text under B's header.
|
||||
let live = true;
|
||||
void api.lanes
|
||||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||
.then(
|
||||
forLane(laneId, (r) =>
|
||||
setText(
|
||||
r.available
|
||||
? {
|
||||
path: r.path,
|
||||
lines: r.lines,
|
||||
truncated: r.truncated,
|
||||
binary: r.binary,
|
||||
sha: r.sha,
|
||||
}
|
||||
: null
|
||||
)
|
||||
forLane(
|
||||
laneId,
|
||||
(r) =>
|
||||
live &&
|
||||
setText(
|
||||
r.available
|
||||
? {
|
||||
path: r.path,
|
||||
lines: r.lines,
|
||||
truncated: r.truncated,
|
||||
binary: r.binary,
|
||||
sha: r.sha,
|
||||
}
|
||||
: null
|
||||
)
|
||||
)
|
||||
)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
||||
// The editor's original side, read beside the working file. A file that is
|
||||
// not there (new since) diffs against nothing: every line is added.
|
||||
@@ -1196,10 +1217,17 @@ export function LaneChangesPanel({
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
||||
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
||||
let live = true;
|
||||
void api.lanes.gitFile(target, openFile, rev).then(
|
||||
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
|
||||
forLane(laneId, () => setBaseText(""))
|
||||
forLane(
|
||||
laneId,
|
||||
(r) => live && setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")
|
||||
),
|
||||
forLane(laneId, () => live && setBaseText(""))
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1422,6 +1450,27 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||
// dialog that names how many untracked files it will delete.
|
||||
const discardAll = async () => {
|
||||
const rows = files.filter((f) => !f.conflicted);
|
||||
if (!rows.length) return;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardAllTitle"),
|
||||
message: t("changes.discardAllConfirm", {
|
||||
count: rows.length,
|
||||
untracked: rows.filter((f) => f.untracked).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardAll"),
|
||||
});
|
||||
if (ok)
|
||||
await act(
|
||||
"discard",
|
||||
rows.map((f) => f.path),
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
const mergeAct = async (
|
||||
action: "resolve" | "merge-commit" | "merge-abort",
|
||||
@@ -1728,7 +1777,12 @@ export function LaneChangesPanel({
|
||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
// Pushed: the server refuses it (EPUSHED), so say why here
|
||||
// instead of failing after the confirm.
|
||||
disabled={
|
||||
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
|
||||
}
|
||||
title={headPushed ? t("changes.undoCommitPushed") : undefined}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
@@ -1988,6 +2042,8 @@ export function LaneChangesPanel({
|
||||
});
|
||||
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
// Rendered only where the toggle is offered: a whole markdown file.
|
||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
@@ -2751,6 +2807,17 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy}
|
||||
onClick={() => void discardAll()}
|
||||
title={t("changes.discardAllTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2990,7 +3057,7 @@ export function LaneChangesPanel({
|
||||
range === "commit" && sha ? sha : "work"
|
||||
)}
|
||||
/>
|
||||
) : !mdPreview &&
|
||||
) : !mdShown &&
|
||||
(whole || editable) &&
|
||||
(draft !== null || (editable && text === null)) ? (
|
||||
// The editor, once both texts are in: not the diff first.
|
||||
@@ -3046,7 +3113,7 @@ export function LaneChangesPanel({
|
||||
</Suspense>
|
||||
)
|
||||
) : whole ? (
|
||||
text && mdPreview ? (
|
||||
text && mdShown ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||
<MarkdownPreview text={text.lines.join("\n")} />
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* first, and a session some tab already holds opens THAT tab instead of a
|
||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||
* it is live the composer is off. Only Claude and Codex can resume a session by id —
|
||||
* any other tool's session offers a new session from the workspace home
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `resumeById` (omp) can resume a session by id — any other tool's session
|
||||
* offers a new session from the workspace home
|
||||
* instead. The header carries the transcript page's Resume/Fork
|
||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
|
||||
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
|
||||
import i18n from "../../i18n";
|
||||
import { liveRunFor } from "./WorkspaceSessionList";
|
||||
import { SessionResumeButton } from "./SessionResumeButton";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** A message for a session that a tab already holds goes into THAT tab as a
|
||||
* paste, once its terminal is on screen (it mounts after the navigation);
|
||||
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
|
||||
}
|
||||
|
||||
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
|
||||
// Before the catalog loads: the two hand-written launchers resume by id.
|
||||
const RESUMABLE = new Set(["claude", "codex"]);
|
||||
|
||||
export function SessionReplay({
|
||||
@@ -65,6 +68,7 @@ export function SessionReplay({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { byId } = useAiTools();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -88,9 +92,9 @@ export function SessionReplay({
|
||||
}, [sessionId]);
|
||||
|
||||
const tool = session?.tool || "claude";
|
||||
const resumable = RESUMABLE.has(tool);
|
||||
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
|
||||
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
||||
const text = promptWithImages(prompt, images);
|
||||
const text = promptWithImages(prompt, images, tool === "claude");
|
||||
|
||||
const send = async () => {
|
||||
if (!text || busy || uploading || livePane || !resumable) return;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file SessionResumeButton.tsx
|
||||
* @description The transcript page's explicit Resume and Fork for a Claude
|
||||
* session inside a workspace. Opening a session never resumes it; this is the
|
||||
* session inside a workspace (Resume only for a catalog tool that resumes by
|
||||
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
|
||||
* one place that does, behind a warning, because a resume re-reads the whole
|
||||
* transcript into context. Fork resumes it into a NEW session id
|
||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
@@ -54,6 +56,7 @@ export function SessionResumeButton({
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const { byId } = useAiTools();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -70,12 +73,13 @@ export function SessionResumeButton({
|
||||
};
|
||||
}, [session.id]);
|
||||
|
||||
// Claude only: a Codex session id is not what
|
||||
// `codex resume` takes for every kind of thread.
|
||||
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
|
||||
// Codex session id is not what `codex resume` takes for every kind of thread.
|
||||
const tool = session.tool || "claude";
|
||||
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
|
||||
// The workspace that owns it, else the one whose folder holds it.
|
||||
const lane =
|
||||
tool !== "claude" || !session.cwd
|
||||
(tool !== "claude" && !catalogResume) || !session.cwd
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
@@ -140,7 +144,7 @@ export function SessionResumeButton({
|
||||
{t("sessions.resume")}
|
||||
</button>
|
||||
)}
|
||||
{resumable && (
|
||||
{resumable && tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming("fork")}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 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
|
||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
@@ -20,7 +20,6 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
MoreHorizontal,
|
||||
ArrowUp,
|
||||
SquareTerminal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
@@ -76,7 +76,7 @@ export {
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Recents' page size; Show more adds another. */
|
||||
const RECENT_PAGE = 30;
|
||||
@@ -165,12 +165,14 @@ 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;
|
||||
// The account's own default (first-run setup) — never bypass.
|
||||
// The account's own defaults (first-run setup, Settings → AI tools →
|
||||
// Session defaults); a pick here is for this session only.
|
||||
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
|
||||
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
|
||||
// Off for every tool but Codex, which starts from the account's default.
|
||||
const [bypass, setBypass] = useState(false);
|
||||
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
|
||||
const canBypass = !claude && !!entry?.bypass;
|
||||
const [effort, setEffort] = useState<EffortLevel>("");
|
||||
const hasEffort = claude || codex;
|
||||
@@ -254,7 +256,7 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
|
||||
const text = promptWithImages(prompt, images);
|
||||
const text = promptWithImages(prompt, images, claude);
|
||||
const start = async () => {
|
||||
// Enter reaches here past the disabled button: the same guards again.
|
||||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||
@@ -607,15 +609,25 @@ function HomeBody({
|
||||
</div>
|
||||
)}
|
||||
{canBypass && (
|
||||
<span className="px-2">
|
||||
<Checkbox
|
||||
tone="danger"
|
||||
checked={bypass}
|
||||
onChange={setBypass}
|
||||
label={t("run:fields.bypass")}
|
||||
labelClassName="text-xs text-status-danger"
|
||||
// A chip like the selects beside it: quiet when off, red when on.
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={bypass}
|
||||
data-testid="workspace-home-bypass"
|
||||
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
|
||||
onClick={() => setBypass(!bypass)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
|
||||
bypass
|
||||
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Zap
|
||||
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{danger && (
|
||||
|
||||
@@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => {
|
||||
)
|
||||
);
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("sends partial only when the box is ticked", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 7 }),
|
||||
created: true,
|
||||
path: "/home/an/code/big",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "https://git.example.com/o/big.git");
|
||||
await user.click(screen.getByTestId("add-lane-clone-partial"));
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file Tests for CloneProgress: a queued clone shows its place in the
|
||||
* server's clone queue, a running one git's phase, percent and speed with a
|
||||
* bar, and nothing shows before the server knows the token. Also that
|
||||
* `newCloneToken` makes a token the server accepts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneProgress: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type CloneProgressView } from "../../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "../CloneProgress";
|
||||
|
||||
const base: CloneProgressView = {
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 2,
|
||||
waiting: 1,
|
||||
limit: 2,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
};
|
||||
|
||||
describe("CloneProgress", () => {
|
||||
// Braces: a function returned from beforeEach is run as its teardown.
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockReset();
|
||||
});
|
||||
|
||||
it("shows the clone's place in the queue while it waits", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
state: "queued",
|
||||
position: 1,
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows git's phase, percent and speed with a bar while it runs", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
const line = await screen.findByTestId("clone-progress");
|
||||
expect(line).toHaveTextContent("Downloading 45%");
|
||||
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
});
|
||||
|
||||
it("shows nothing while the server does not know the token yet", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
|
||||
);
|
||||
render(<CloneProgress token="t" />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("clone-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("makes tokens the server accepts", () => {
|
||||
const a = newCloneToken();
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
|
||||
expect(newCloneToken()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login.
|
||||
* re-runs only the failed URLs with that login. An SSH link while the
|
||||
* account has no key is never sent — the user is pointed at Settings → Git —
|
||||
* and an HTTPS host with no saved login is flagged before Clone. The
|
||||
* partial-clone box adds `partial: true` to every clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -11,16 +14,20 @@ import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() } },
|
||||
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||
// Unknown identity: nothing is checked up front.
|
||||
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||
});
|
||||
|
||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -53,9 +60,61 @@ describe("CloneSourcesForm", () => {
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("asks for partial clones when the box is ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
|
||||
await user.click(screen.getByTestId("workspace-clone-partial"));
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
url: "https://h/big.git",
|
||||
partial: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("tells an SSH link from an HTTPS one", () => {
|
||||
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
|
||||
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
|
||||
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
|
||||
expect(cloneTarget("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue({
|
||||
ssh: null,
|
||||
sshKeys: [],
|
||||
https: [],
|
||||
} as unknown as GitIdentity);
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("workspace-clone-input"),
|
||||
"git@github.com:o/r.git\nhttps://h.test/a.git"
|
||||
);
|
||||
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
|
||||
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
|
||||
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
url: "https://h.test/a.git",
|
||||
progressToken: expect.any(String),
|
||||
});
|
||||
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty.
|
||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||
* when the viewer may not open that home).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -21,11 +23,13 @@ import { confirmDialog } from "../../../lib/confirm";
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||
source ? { lane, source } : session ? { lane, session } : lane,
|
||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -43,6 +47,8 @@ vi.mock("../../../lib/api", () => ({
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadBatch: vi.fn(),
|
||||
uploadCheck: vi.fn(),
|
||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||
},
|
||||
},
|
||||
@@ -194,6 +200,41 @@ describe("FileExplorer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
||||
1,
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function)
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a batch over 500 MB before sending anything", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
@@ -431,4 +472,141 @@ describe("FileExplorer", () => {
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a user's workspaces down under their home; an unowned one stays in the list", async () => {
|
||||
localStorage.removeItem("agenthub.files.homesOpen");
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", owner_id: 7, owner_name: "bob" },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const toggle = await screen.findByRole("button", { name: "Show or hide Bob’s workspaces" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||
expect(within(places).queryByRole("button", { name: "two" })).toBeNull();
|
||||
expect(within(places).getByRole("button", { name: "one" })).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
await user.click(within(places).getByRole("button", { name: "two" }));
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
expect(localStorage.getItem("agenthub.files.homesOpen")).toContain("bob");
|
||||
});
|
||||
|
||||
it("groups an owner whose home the viewer cannot open (an analyst) under a row that only folds", async () => {
|
||||
localStorage.removeItem("agenthub.files.homesOpen");
|
||||
vi.mocked(api.files.homes).mockResolvedValue({
|
||||
homes: [{ id: "me", label: "An", readOnly: false }],
|
||||
});
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{
|
||||
id: 3,
|
||||
title: "three",
|
||||
cwd: "/w/three",
|
||||
owner_id: 9,
|
||||
owner_name: "cara",
|
||||
owner_display_name: "Cara",
|
||||
},
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||
expect(within(places).getByText("Users")).toBeInTheDocument();
|
||||
expect(within(places).queryByRole("button", { name: "three" })).toBeNull();
|
||||
await user.click(within(places).getByRole("button", { name: /^Cara/ }));
|
||||
expect(api.files.list).not.toHaveBeenCalledWith({ home: "cara" }, "");
|
||||
expect(within(places).getByRole("button", { name: "three" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileExplorer — New → Clone repository", () => {
|
||||
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
|
||||
return screen.getByTestId("fx-clone-dialog");
|
||||
};
|
||||
|
||||
it("clones into the open folder of the workspace and says where", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
|
||||
created: true,
|
||||
path: "/w/one/src/repo",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
fireEvent.doubleClick(await screen.findByText("src"));
|
||||
await screen.findByText("x.js");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith({
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
lane: { id: 9, title: "r", cwd: "/w/one/r" },
|
||||
created: true,
|
||||
path: "/w/one/r",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: "/w/one",
|
||||
credentials: { username: "ann", password: "tok", remember: true },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
|
||||
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.click(row("a.txt"));
|
||||
const dialog = await openClone(user);
|
||||
const url = within(dialog).getByLabelText("Repository URL");
|
||||
await user.type(url, "abc{Backspace}{Delete}");
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
expect(url).toHaveValue("ab");
|
||||
});
|
||||
|
||||
it("is not offered in an extra folder", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,7 +514,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
||||
it("opens a whole markdown file rendered, as source after Raw, and rendered again after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -534,12 +534,13 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
@@ -770,6 +771,24 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -1062,6 +1081,20 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer Undo for a top commit its upstream already holds", async () => {
|
||||
withParent();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(
|
||||
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
|
||||
await waitFor(() => expect(undo).toBeDisabled());
|
||||
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
|
||||
});
|
||||
|
||||
it("cherry-picks only from another branch's history", async () => {
|
||||
withParent();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||
|
||||
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
|
||||
// As the Workspace page: `&run=` replaces the home with the session view.
|
||||
function HomeUntilRun() {
|
||||
const [params] = useSearchParams();
|
||||
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
|
||||
return params.get("run") ? null : (
|
||||
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
|
||||
);
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
|
||||
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
||||
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
||||
import {
|
||||
DEFAULT_MODES,
|
||||
readDefaultMode,
|
||||
readDefaultTool,
|
||||
SAFE_MODES,
|
||||
writeDefaultMode,
|
||||
writeDefaultTool,
|
||||
type DefaultMode,
|
||||
@@ -310,7 +310,7 @@ function AgentStep() {
|
||||
writeDefaultMode(m);
|
||||
setMode(m);
|
||||
}}
|
||||
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
||||
options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @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
|
||||
* is sent back; the wizard never offers bypass (a Settings default may hold it); 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>
|
||||
@@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({
|
||||
}));
|
||||
|
||||
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
||||
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
|
||||
import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults";
|
||||
import { OnboardingWizard } from "../OnboardingWizard";
|
||||
import { OnboardingTour, startTour } from "../OnboardingTour";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
@@ -98,10 +98,12 @@ describe("account preferences", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("never keeps bypass as the starting permission mode", () => {
|
||||
it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
|
||||
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||
expect(readDefaultMode()).toBe("auto");
|
||||
writeDefaultMode("bypassPermissions" as never);
|
||||
expect(readDefaultMode()).toBe("bypassPermissions");
|
||||
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||
expect(SAFE_MODES).not.toContain("bypassPermissions");
|
||||
writeDefaultMode("nonsense" as never);
|
||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
||||
writeDefaultMode("plan");
|
||||
expect(readDefaultMode()).toBe("plan");
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @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 —
|
||||
* files (the prompt box's own picker), Choose a file on this computer (a
|
||||
* browser on the dashboard's own machine only: the OS's own picker,
|
||||
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
|
||||
* nothing uploaded), 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,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
History,
|
||||
Paperclip,
|
||||
Plug,
|
||||
@@ -29,7 +33,14 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
|
||||
import {
|
||||
isLocalBrowser,
|
||||
localAttachment,
|
||||
projectAttachment,
|
||||
recentUploads,
|
||||
type Attachment,
|
||||
} from "../../lib/promptAttach";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
@@ -244,6 +255,17 @@ export function ChatAddMenu({
|
||||
close();
|
||||
onAttach([item]);
|
||||
};
|
||||
const pickLocal = async () => {
|
||||
close();
|
||||
// The dialog opens on the desktop, sometimes behind this window.
|
||||
toast.info(t("chat.pickLocalOpen"));
|
||||
try {
|
||||
const r = await api.lanes.pickPath("file", cwd || undefined);
|
||||
if (r.path) onAttach([localAttachment(r.path)]);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
const manage = (tab: "skills" | "plugins") => {
|
||||
close();
|
||||
navigate(`/cc-config?tab=${tab}`);
|
||||
@@ -291,6 +313,16 @@ export function ChatAddMenu({
|
||||
onUpload();
|
||||
}}
|
||||
/>
|
||||
{isLocalBrowser() && (
|
||||
<Row
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
label={t("chat.pickLocal")}
|
||||
title={t("chat.pickLocalHint")}
|
||||
testId="chat-add-pick-local"
|
||||
onHover={() => setSub(null)}
|
||||
onClick={() => void pickLocal()}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
label={t("chat.projectFiles")}
|
||||
|
||||
@@ -18,15 +18,41 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Share2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { toast } from "../../lib/toast";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import {
|
||||
RENDERED,
|
||||
artifactDocument,
|
||||
artifactKind,
|
||||
artifactLang,
|
||||
artifactVersions,
|
||||
frameSandbox,
|
||||
parseCsv,
|
||||
type ArtifactKind,
|
||||
} from "../../lib/artifactView";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
export interface PreviewScope {
|
||||
@@ -146,33 +172,96 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
return content;
|
||||
}
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
|
||||
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
|
||||
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
|
||||
const [head, ...body] = rows;
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
{head && (
|
||||
<thead className="sticky top-0 bg-surface-2">
|
||||
<tr>
|
||||
{head.map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{body.map((r, i) => (
|
||||
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
|
||||
{r.map((c, j) => (
|
||||
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
|
||||
* scripts (none for an SVG) but never the dashboard's origin. */
|
||||
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
|
||||
const { theme } = useTheme();
|
||||
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
|
||||
return (
|
||||
<iframe
|
||||
data-testid="artifact-frame"
|
||||
title={title}
|
||||
srcDoc={doc ?? ""}
|
||||
sandbox={frameSandbox(kind)}
|
||||
referrerPolicy="no-referrer"
|
||||
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type View = "preview" | "code";
|
||||
|
||||
/** A file's first tab: Preview where it renders — Markdown as Settings → Appearance says. */
|
||||
function defaultView(path: string): View {
|
||||
const kind = artifactKind(path);
|
||||
if (!RENDERED.has(kind)) return "code";
|
||||
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
|
||||
}
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
const { t } = useTranslation("run");
|
||||
const loaded = useContent(path, scope);
|
||||
const kind = artifactKind(path);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const content: Content =
|
||||
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
||||
if (content.kind === "loading") {
|
||||
const shown: Content =
|
||||
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
|
||||
if (shown.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") {
|
||||
if (shown.kind === "image") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||
<img
|
||||
src={content.src}
|
||||
src={shown.src}
|
||||
alt={baseName(path)}
|
||||
onError={() => setBroken(content.src)}
|
||||
onError={() => setBroken(shown.src)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "none") {
|
||||
if (shown.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" />
|
||||
@@ -181,69 +270,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
</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>
|
||||
const note = shown.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
);
|
||||
if (view === "code" || !RENDERED.has(kind)) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "markdown") {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<MarkdownPreview text={shown.text} className="p-4" />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||
}
|
||||
|
||||
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
||||
const iconBtn =
|
||||
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
/** Saves text (or an image's URL) as a file through the browser. */
|
||||
function download(name: string, content: Content) {
|
||||
const a = document.createElement("a");
|
||||
if (content.kind === "image") a.href = content.src;
|
||||
else if (content.kind === "text")
|
||||
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
|
||||
else return;
|
||||
a.download = name;
|
||||
a.click();
|
||||
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
|
||||
* versions the conversation made of it, Copy, Download, Share to the
|
||||
* Artifacts Hub, full screen, Close. */
|
||||
export function FilePreview({
|
||||
path,
|
||||
scope,
|
||||
onClose,
|
||||
className = "",
|
||||
messages,
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
/** The conversation, for the versions its Write/Edit calls left. */
|
||||
messages?: TranscriptMessage[];
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
return (
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(() => defaultView(path));
|
||||
const [full, setFull] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
() => (messages ? artifactVersions(messages, path) : []),
|
||||
[messages, path]
|
||||
);
|
||||
// null: the newest — the file as it is now.
|
||||
const [picked, setPicked] = useState<number | null>(null);
|
||||
// Another file opens on its own default tab, at its newest version.
|
||||
useEffect(() => {
|
||||
setPicked(null);
|
||||
setView(defaultView(path));
|
||||
}, [path]);
|
||||
const last = Math.max(versions.length - 1, 0);
|
||||
const at = picked ?? last;
|
||||
const older = picked !== null && picked < last ? versions[picked] : null;
|
||||
const content: Content =
|
||||
older && older.content !== null
|
||||
? { kind: "text", text: older.content, truncated: false }
|
||||
: loaded;
|
||||
const known = (i: number) => i === last || versions[i]?.content != null;
|
||||
const step = (dir: -1 | 1) => {
|
||||
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
|
||||
if (known(i)) return setPicked(i === last ? null : i);
|
||||
}
|
||||
};
|
||||
const share = async () => {
|
||||
if (sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
|
||||
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
|
||||
await api.artifacts.pinFile({
|
||||
laneId: scope.laneId,
|
||||
path: file.slice(base.length + 1),
|
||||
title: displayName(path),
|
||||
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
|
||||
});
|
||||
} else if (content.kind === "text") {
|
||||
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
|
||||
title: displayName(path),
|
||||
});
|
||||
} else throw new Error(t("chat.artifact.cannotShare"));
|
||||
toast.success(t("chat.artifact.shared"));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
const panel = (
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
||||
className={
|
||||
full
|
||||
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
: `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}>
|
||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
|
||||
>
|
||||
{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>
|
||||
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
|
||||
{versions.length > 1 && (
|
||||
<span
|
||||
data-testid="artifact-versions"
|
||||
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
disabled={![...Array(at).keys()].some(known)}
|
||||
aria-label={t("chat.artifact.older")}
|
||||
title={t("chat.artifact.older")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
disabled={at >= last}
|
||||
aria-label={t("chat.artifact.newer")}
|
||||
title={t("chat.artifact.newer")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
|
||||
{RENDERED.has(kind) && kind !== "image" && (
|
||||
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
|
||||
{(["preview", "code"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === v}
|
||||
data-testid={`artifact-tab-${v}`}
|
||||
onClick={() => setView(v)}
|
||||
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
|
||||
>
|
||||
{t(`chat.artifact.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<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"
|
||||
data-testid="artifact-copy"
|
||||
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
|
||||
title={t("chat.artifact.copy")}
|
||||
aria-label={t("chat.artifact.copy")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(displayName(path), content)}
|
||||
disabled={content.kind !== "text" && content.kind !== "image"}
|
||||
title={t("chat.artifact.download")}
|
||||
aria-label={t("chat.artifact.download")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-share"
|
||||
onClick={() => void share()}
|
||||
disabled={sharing || content.kind === "loading" || content.kind === "none"}
|
||||
title={t("chat.artifact.share")}
|
||||
aria-label={t("chat.artifact.share")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{sharing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 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"
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} scope={scope} />
|
||||
<Body path={path} content={content} view={view} />
|
||||
</section>
|
||||
);
|
||||
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||
return full ? createPortal(panel, document.body) : panel;
|
||||
}
|
||||
|
||||
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* new lane's own live session for its slot, or an empty pane — never the
|
||||
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
||||
* 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
|
||||
* A pane of a `CHAT_TOOLS` tool (Claude, Codex, omp, Cursor, Antigravity) 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 each SESSION's own (`useSessionChat`, the toggle
|
||||
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||||
@@ -29,9 +30,7 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
import { CHAT_TOOLS, useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
@@ -218,6 +217,7 @@ export function LaneConsolePane({
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={autoFocus && !(chatable && chat)}
|
||||
covered={!!(chatable && chat)}
|
||||
tool={handle.tool}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// the tab bar moves on (another tab, or the home's prompt box)
|
||||
// rather than leaving a dead terminal the user has to click out of.
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
|
||||
* permission or trust prompt, Codex's approval — is read off the terminal
|
||||
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
|
||||
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
|
||||
* that press its keys there (`PaneMenuCard`) — a permission prompt with its tool, command and a deny-with-note
|
||||
* (`PermissionMenuCard`); its "Type something" row opens
|
||||
* a field in the card, whose text is typed there as the answer
|
||||
* (`answerRunMenu`).
|
||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||
@@ -56,7 +57,17 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
MessageCircleQuestion,
|
||||
ShieldQuestion,
|
||||
Square,
|
||||
SquareCheck,
|
||||
} from "lucide-react";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import {
|
||||
@@ -77,7 +88,11 @@ import { Divider, showTerminal } from "./PaneGrid";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
notePaneFocus,
|
||||
onPaneOutput,
|
||||
stepRunMenu,
|
||||
interruptRun,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
@@ -89,7 +104,9 @@ import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { setTabBadge } from "../../lib/tabBadge";
|
||||
import { fmt } from "../../lib/format";
|
||||
import { agentAwaitingReason } from "../../lib/types";
|
||||
import { agentAwaitingReason, type TranscriptMessage, type WSMessage } from "../../lib/types";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
@@ -100,6 +117,11 @@ const MODES: [PermissionMode, string][] = [
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
// Pane output re-reads the transcript at most this often: a TUI spinner
|
||||
// redraws several times a second, and each read walks the transcript file.
|
||||
const OUTPUT_FETCH_MS = 1000;
|
||||
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||
const OUTPUT_BUSY_MS = 2000;
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||
@@ -117,6 +139,7 @@ const storedSide = () => {
|
||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||
export function contextWindow(model: string | null): number | null {
|
||||
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||
if (!model?.startsWith("claude-")) return null;
|
||||
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
|
||||
? 200_000
|
||||
@@ -125,7 +148,13 @@ export function contextWindow(model: string | null): number | null {
|
||||
|
||||
/** How full the context is: a ring and a percentage, amber then red as
|
||||
* Claude Code's auto-compact comes near. */
|
||||
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||
function ContextMeter({
|
||||
tokens,
|
||||
model,
|
||||
}: {
|
||||
tokens: number;
|
||||
model: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const size = contextWindow(model);
|
||||
if (!size || !tokens) return null;
|
||||
@@ -212,33 +241,222 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
||||
);
|
||||
}
|
||||
|
||||
/** A permission prompt (Claude's tool approval, Codex's command approval,
|
||||
* the folder-trust question): it offers a Yes and a No. */
|
||||
export function isPermissionMenu(menu: PaneMenu): boolean {
|
||||
if (menu.multi || menu.tabs) return false;
|
||||
const labels = menu.options.map((o) => o.label);
|
||||
return labels.some((l) => /^Yes\b/i.test(l)) && labels.some((l) => /^No\b/i.test(l));
|
||||
}
|
||||
|
||||
/** A menu row's label without its key hint (`Yes, proceed (y)`). */
|
||||
const bareLabel = (label: string) => label.replace(/\s+\((?:[a-z]|esc|shift\+tab)\)$/i, "");
|
||||
|
||||
/** A permission prompt read off the terminal: the tool, what it will run, and the CLI's own
|
||||
* answers as buttons — each still presses its key in the pane. "No, and
|
||||
* tell … differently" also takes a note, sent as the next message once the
|
||||
* CLI is back at its prompt. */
|
||||
function PermissionMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onDenyNote,
|
||||
onCancel,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onDenyNote: (index: number, note: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [noting, setNoting] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const q = menu.question.split("\n");
|
||||
const title = menu.detail?.[0] ?? q[0] ?? "";
|
||||
const body = (menu.detail ? menu.detail.slice(1) : q.slice(1)).join("\n");
|
||||
const ask = menu.detail ? menu.question : "";
|
||||
const yes = menu.options.findIndex((o) => /^Yes\b/i.test(o.label));
|
||||
const tell = menu.options.findIndex((o) => /^No, and tell\b/i.test(o.label));
|
||||
const sendNote = () => {
|
||||
if (note.trim() && tell >= 0) onDenyNote(tell, note.trim());
|
||||
};
|
||||
return (
|
||||
<div data-testid="pane-chat-permission" className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-status-warning" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{title}
|
||||
</p>
|
||||
{ask && <p className="text-xs text-fg-muted">{ask}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{body && (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-fg-secondary">
|
||||
{body}
|
||||
</pre>
|
||||
)}
|
||||
{noting ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1">
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="pane-chat-permission-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
sendNote();
|
||||
} else if (e.key === "Escape") setNoting(false);
|
||||
}}
|
||||
placeholder={t("chat.request.notePlaceholder")}
|
||||
aria-label={t("chat.request.notePlaceholder")}
|
||||
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendNote}
|
||||
disabled={!note.trim()}
|
||||
className="btn-secondary text-xs disabled:opacity-40"
|
||||
>
|
||||
{t("chat.request.send")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{menu.options.map((o, i) => (
|
||||
<button
|
||||
key={`${i}-${o.label}`}
|
||||
type="button"
|
||||
data-testid="pane-chat-permission-option"
|
||||
autoFocus={i === yes}
|
||||
onClick={() => onChoose(i)}
|
||||
title={o.hint || undefined}
|
||||
className={`${i === yes ? "btn-primary" : "btn-secondary"} max-w-full break-words text-left text-xs`}
|
||||
>
|
||||
{bareLabel(o.label)}
|
||||
</button>
|
||||
))}
|
||||
{tell >= 0 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-permission-tell"
|
||||
onClick={() => setNoting(true)}
|
||||
className="text-xs text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{t("chat.request.denyWithNote")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-auto rounded-md border border-border px-2 py-0.5 text-xs text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
{t("chat.menu.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
||||
* pane; its "Type something" row opens a field right there instead —
|
||||
* pressing that row's digit again would type the digit into the CLI's own. */
|
||||
* pressing that row's digit again would type the digit into the CLI's own.
|
||||
* A permission prompt takes its own layout (`PermissionMenuCard`), any
|
||||
* other menu the question layout. */
|
||||
export function PaneMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onAnswer,
|
||||
onCancel,
|
||||
onStep,
|
||||
onDenyNote,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onAnswer: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
/** Previous (-1) / next (1) question of a multi-question AskUserQuestion. */
|
||||
onStep?: (dir: -1 | 1) => void;
|
||||
/** A permission prompt's "No, and tell …", with the note to send after it. */
|
||||
onDenyNote?: (index: number, note: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const submit = () => draft.trim() && onAnswer(draft);
|
||||
if (isPermissionMenu(menu))
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
<PermissionMenuCard
|
||||
menu={menu}
|
||||
onChoose={onChoose}
|
||||
onDenyNote={onDenyNote ?? ((i) => onChoose(i))}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
{menu.tabs && (
|
||||
<div
|
||||
data-testid="pane-chat-menu-tabs"
|
||||
className="mb-2 flex flex-wrap items-center gap-1 text-xs"
|
||||
>
|
||||
{onStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStep(-1)}
|
||||
aria-label={t("chat.menu.prev")}
|
||||
title={t("chat.menu.prev")}
|
||||
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{menu.tabs.map((tab, i) => (
|
||||
<span
|
||||
key={`${i}-${tab.label}`}
|
||||
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 ${
|
||||
i === menu.tab
|
||||
? "border-accent/50 bg-surface-2 text-fg-primary"
|
||||
: "border-border text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{tab.done ? (
|
||||
<Check className="h-3 w-3 text-accent" />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
)}
|
||||
{tab.label}
|
||||
</span>
|
||||
))}
|
||||
{onStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStep(1)}
|
||||
aria-label={t("chat.menu.next")}
|
||||
title={t("chat.menu.next")}
|
||||
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{menu.question && (
|
||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<MessageCircleQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{menu.options.map((o, i) =>
|
||||
@@ -338,9 +556,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
||||
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
||||
const draftKey = sessionId;
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(draftKey).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(draftKey).images);
|
||||
useEffect(() => writeChatDraft(draftKey, prompt, images), [draftKey, prompt, images]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
@@ -403,7 +622,11 @@ export const PaneChat = memo(function PaneChat({
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => {
|
||||
const main = r.agents.filter((a) => a.type === "main");
|
||||
setWorking(main.some((a) => a.status === "working"));
|
||||
// A fresh start, resume or `/clear` leaves main "working" at an
|
||||
// empty prompt (SessionStart's flag) until the first prompt.
|
||||
setWorking(
|
||||
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
|
||||
);
|
||||
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||
})
|
||||
.catch(() => {}),
|
||||
@@ -412,6 +635,46 @@ export const PaneChat = memo(function PaneChat({
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
// The terminal underneath drew something: the CLI is at work, so its
|
||||
// transcript may have grown — read it now, for any tool, hooks or not. A
|
||||
// spinner redraws all the time, hence the throttle.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const poke = throttle(
|
||||
() =>
|
||||
eventBus.publish({
|
||||
type: "transcript_changed",
|
||||
data: { session_id: sessionId },
|
||||
} as unknown as WSMessage),
|
||||
OUTPUT_FETCH_MS
|
||||
);
|
||||
const off = onPaneOutput(runId, poke);
|
||||
return () => {
|
||||
off();
|
||||
poke.cancel();
|
||||
};
|
||||
}, [runId, sessionId]);
|
||||
// The terminal's own word on a turn: its busy line (BUSY_LINE) while it
|
||||
// shows, else — a tool whose line we do not know — output in the last
|
||||
// OUTPUT_BUSY_MS. Hooks miss a tool that sends none (omp, Cursor, Codex
|
||||
// without its opt-in hooks), and arrive late on a dashboard they skip.
|
||||
const [screenBusy, setScreenBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let last = 0;
|
||||
const off = onPaneOutput(runId, () => (last = Date.now()));
|
||||
const read = () => {
|
||||
const known = readRunBusy(runId, run.tool);
|
||||
setScreenBusy(known ?? (last > 0 && Date.now() - last < OUTPUT_BUSY_MS));
|
||||
};
|
||||
read();
|
||||
const timer = window.setInterval(() => {
|
||||
if (isShown(onScreen)) read();
|
||||
}, 700);
|
||||
return () => {
|
||||
off();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [runId, run.tool, onScreen]);
|
||||
useLiveRefresh(readWorking, {
|
||||
throttleMs: 500,
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
@@ -440,6 +703,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId, onScreen]);
|
||||
const busy = working || screenBusy;
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
@@ -452,15 +716,22 @@ export const PaneChat = memo(function PaneChat({
|
||||
setFollow((n) => n + 1);
|
||||
} else toast.error(t("chat.notSent"));
|
||||
};
|
||||
// "No, and tell … differently": the CLI drops back to its prompt, where
|
||||
// the note is what the user would type next.
|
||||
const denyWithNote = (index: number, note: string) => {
|
||||
if (!menu || !chooseRunOption(runId, menu, index)) return void toast.error(t("chat.notSent"));
|
||||
setMenu(null);
|
||||
window.setTimeout(() => void command(note), 800);
|
||||
};
|
||||
|
||||
// A hidden tab says when this session waits on the user or its turn ended.
|
||||
const was = useRef({ working: false, asking: false });
|
||||
useEffect(() => {
|
||||
if (asking) setTabBadge(runId, "waiting");
|
||||
else if (working) setTabBadge(runId, null);
|
||||
else if (busy) setTabBadge(runId, null);
|
||||
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
||||
was.current = { working, asking };
|
||||
}, [runId, working, asking]);
|
||||
was.current = { working: busy, asking };
|
||||
}, [runId, busy, asking]);
|
||||
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
||||
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
||||
tokens: 0,
|
||||
@@ -476,7 +747,20 @@ export const PaneChat = memo(function PaneChat({
|
||||
|
||||
// Stable, so typing in the prompt box does not re-render the conversation
|
||||
// (ConversationView is memoized).
|
||||
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
|
||||
const openTerminal = useCallback(
|
||||
() => showTerminal(runId, sessionId, run.tool),
|
||||
[runId, sessionId, run.tool]
|
||||
);
|
||||
// The conversation on screen: an artifact's versions are rebuilt from it.
|
||||
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||
// An artifact card opens in this chat's own panel, like Claude Desktop's —
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
(named: string) => {
|
||||
setPreview(resolveIn(cwd, named));
|
||||
},
|
||||
[cwd]
|
||||
);
|
||||
const openFile = useCallback(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
@@ -514,7 +798,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
|
||||
const send = async () => {
|
||||
if (!text || uploading) return;
|
||||
const out = promptForPane(prompt, images);
|
||||
const out = promptForPane(prompt, images, claude);
|
||||
if (!(await command(out.text, out.files))) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
@@ -526,6 +810,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-prompt-drop
|
||||
// The last chat focused is where the Changes panel's comments go.
|
||||
onFocusCapture={() => notePaneFocus(runId)}
|
||||
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||
>
|
||||
@@ -542,6 +828,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
onContext={onContext}
|
||||
onNotStarted={setNotStarted}
|
||||
onOpenFile={openFile}
|
||||
onOpenArtifact={openArtifact}
|
||||
onMessages={setConvMessages}
|
||||
/>
|
||||
{!outlineOpen && !preview && (
|
||||
<button
|
||||
@@ -558,7 +846,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{menu && (
|
||||
<PaneMenuCard
|
||||
// A new question starts with its field closed.
|
||||
@@ -566,6 +854,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
menu={menu}
|
||||
onChoose={choose}
|
||||
onAnswer={answer}
|
||||
onDenyNote={denyWithNote}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(runId, dir)) toast.error(t("chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(runId)) setMenu(null);
|
||||
else toast.error(t("chat.notSent"));
|
||||
@@ -575,7 +867,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
{/* In the card's place while the CLI asks: hidden, never unmounted,
|
||||
so a draft or an upload in flight survives the question. */}
|
||||
<div
|
||||
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
|
||||
className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
|
||||
>
|
||||
<PromptEditor
|
||||
bare
|
||||
@@ -602,6 +894,26 @@ export const PaneChat = memo(function PaneChat({
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
{run.tool === "codex" &&
|
||||
// Codex's `/model` (then its effort) and `/permissions` are
|
||||
// numbered pickers: typed here, answered on the menu card.
|
||||
(
|
||||
[
|
||||
["/permissions", t("fields.permissionMode")],
|
||||
["/model", t("fields.model")],
|
||||
] as const
|
||||
).map(([line, label]) => (
|
||||
<button
|
||||
key={line}
|
||||
type="button"
|
||||
data-testid={`pane-chat-codex-${line.slice(1)}`}
|
||||
onClick={() => void command(line)}
|
||||
title={t("chat.codexPicker")}
|
||||
className="rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ContextMeter tokens={context.tokens} model={context.model} />
|
||||
{claude && (
|
||||
@@ -617,7 +929,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{working && !text ? (
|
||||
{busy && !text ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-stop"
|
||||
@@ -630,8 +942,13 @@ export const PaneChat = memo(function PaneChat({
|
||||
}}
|
||||
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"
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||
>
|
||||
{/* A turning ring: the turn is still going. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
|
||||
/>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
@@ -657,6 +974,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
messages={convMessages}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when the pane has room, over it when not.
|
||||
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { CHAT_TOOLS, setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptForPane } from "./RunSetup";
|
||||
@@ -238,15 +238,15 @@ 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";
|
||||
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
/** Chat UI ⇄ terminal for a `CHAT_TOOLS` pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
||||
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
||||
* the draft (its files as paths) back into the input box, never sent. */
|
||||
/** A session's chat switched to its terminal: the unsent prompt goes into
|
||||
* the CLI's input box, without Enter. */
|
||||
export function showTerminal(runId: string, sessionId: string) {
|
||||
export function showTerminal(runId: string, sessionId: string, tool?: string) {
|
||||
const draft = readChatDraft(sessionId);
|
||||
const { text, files } = promptForPane(draft.prompt, draft.images);
|
||||
const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude");
|
||||
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
|
||||
setSessionChat(sessionId, false);
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function showTerminal(runId: string, sessionId: string) {
|
||||
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;
|
||||
if (!run?.sessionId || !CHAT_TOOLS.has(run.tool)) return null;
|
||||
const sessionId = run.sessionId;
|
||||
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
||||
return (
|
||||
@@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
if (chat) return showTerminal(run.id, sessionId);
|
||||
if (chat) return showTerminal(run.id, sessionId, run.tool);
|
||||
const draft = readChatDraft(sessionId);
|
||||
const text = takeRunInput(run.id);
|
||||
if (text)
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* as an `@path` chip — so is a local file a browser on the dashboard's
|
||||
* own machine drops or pastes with its `file://` path, never uploaded —
|
||||
* a `bare` one takes a drop anywhere on its box;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
@@ -67,9 +69,15 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
claudeMention,
|
||||
copiedFilePaths,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
isLocalBrowser,
|
||||
localAttachment,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -203,24 +211,35 @@ interface PromptEditorProps {
|
||||
export type PromptImage = { path: string; url?: string; name: string };
|
||||
|
||||
/** The prompt as sent: the text, then each file's saved path — which is how
|
||||
* the CLI is handed an image or a file. */
|
||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||
* the CLI is handed an image or a file; for `claude`, as `@` mentions. */
|
||||
export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
|
||||
return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
|
||||
|
||||
/** The prompt as pasted into a live pane: the text with every non-image file
|
||||
* in it (as `promptWithImages`), and the images' paths apart — each goes in
|
||||
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
|
||||
* platform (a Windows path inside a paste of text stayed a plain path). */
|
||||
/** The prompt as pasted into a live pane. `claude`: one paste, every file an
|
||||
* `@` mention, and a trailing space so a mention at the end does not leave
|
||||
* the `@` file suggester open to swallow the Enter after it. Any other tool:
|
||||
* the text with every non-image file in it, and the images' paths apart,
|
||||
* each in a paste of its own (how `codex` attaches a pasted image path). */
|
||||
export function promptForPane(
|
||||
text: string,
|
||||
images: PromptImage[]
|
||||
images: PromptImage[],
|
||||
claude = false
|
||||
): { text: string; files: string[] } {
|
||||
if (claude) {
|
||||
const all = promptWithImages(text, images, true);
|
||||
return { text: images.length ? `${all} ` : all, files: [] };
|
||||
}
|
||||
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
|
||||
return {
|
||||
text: promptWithImages(text, images.filter((i) => !isImage(i))),
|
||||
text: promptWithImages(
|
||||
text,
|
||||
images.filter((i) => !isImage(i))
|
||||
),
|
||||
files: images.filter(isImage).map((i) => i.path),
|
||||
};
|
||||
}
|
||||
@@ -290,7 +309,7 @@ export function PromptEditor({
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
|
||||
]).then(([c, sk, pl]) => {
|
||||
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||
// installed at both user and project scope is listed once.
|
||||
@@ -390,7 +409,11 @@ export function PromptEditor({
|
||||
// A pasted or dropped file is saved on the server's machine (the same
|
||||
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
|
||||
null
|
||||
);
|
||||
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||
const [limitMb, setLimitMb] = useState(0);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
const uploading = upload === "uploading";
|
||||
@@ -401,20 +424,72 @@ export function PromptEditor({
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
path: saved[i]!.path,
|
||||
// Each file on its own: one refused file does not cost the others.
|
||||
void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
|
||||
const added: PromptImage[] = [];
|
||||
const errors: unknown[] = [];
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === "rejected") return void errors.push(r.reason);
|
||||
const f = picked[i]!;
|
||||
added.push({
|
||||
path: r.value.path,
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
});
|
||||
});
|
||||
if (added.length) {
|
||||
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||
keepError.current = errors.length > 0; // these came with the error
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
})
|
||||
.catch(() => setUpload("failed"));
|
||||
}
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
.find((e) => e?.code === "ETOOBIG");
|
||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||
setUpload(
|
||||
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
|
||||
);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
/** A file manager's copy pasted with no file beside it, only `file://`
|
||||
* text: attached by those paths, as the terminal pastes them. */
|
||||
const pastedLocalFiles = (dt: DataTransfer | null | undefined) => {
|
||||
const paths = isLocalBrowser() ? copiedFilePaths(dt) : null;
|
||||
if (!paths) return false;
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
return true;
|
||||
};
|
||||
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||
* dashboard's machine and the server finds the same files there (a file
|
||||
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
||||
const attachDropped = (
|
||||
dt: DataTransfer | null | undefined,
|
||||
list: FileList | null | undefined
|
||||
) => {
|
||||
const files = Array.from(list ?? []);
|
||||
if (files.length === 0) return false;
|
||||
const paths = localCandidates(dt, files);
|
||||
if (!paths) return attachImages(files);
|
||||
setUpload("uploading");
|
||||
void sameFilesHere(paths, files).then((same) => {
|
||||
if (!same) return void attachImages(files);
|
||||
setUpload(null);
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
});
|
||||
return true;
|
||||
};
|
||||
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||
// upload's error; not the files that uploaded beside the one refused.
|
||||
const keepError = useRef(false);
|
||||
const imageCount = useRef(images.length);
|
||||
useEffect(() => {
|
||||
const grew = images.length > imageCount.current;
|
||||
imageCount.current = images.length;
|
||||
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
|
||||
keepError.current = false;
|
||||
}, [images.length]);
|
||||
const removeImage = (img: PromptImage) => {
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
@@ -443,7 +518,7 @@ export function PromptEditor({
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
||||
else {
|
||||
// Only text (a file manager's or an editor's `file://` list, a
|
||||
// selection, a link): what the terminal beneath would paste.
|
||||
@@ -608,7 +683,8 @@ export function PromptEditor({
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||
else if (pastedLocalFiles(e.clipboardData)) 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
|
||||
@@ -652,10 +728,10 @@ export function PromptEditor({
|
||||
</div>
|
||||
{upload && (
|
||||
<p
|
||||
role={upload === "failed" ? "alert" : "status"}
|
||||
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
||||
role={upload === "uploading" ? "status" : "alert"}
|
||||
className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
|
||||
>
|
||||
{t(`drop.${upload}`)}
|
||||
{t(`drop.${upload}`, { mb: limitMb })}
|
||||
</p>
|
||||
)}
|
||||
{kind && (
|
||||
@@ -797,7 +873,7 @@ export function CwdAutocomplete({
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
} else if (isSubmitEnter(e)) {
|
||||
if (open && flat[active]) {
|
||||
e.preventDefault();
|
||||
choose(flat[active]);
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
|
||||
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -62,12 +65,20 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
|
||||
import {
|
||||
claudePastes,
|
||||
copiedFilePaths,
|
||||
droppedFilePaths,
|
||||
isLocalBrowser,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed";
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -160,6 +171,10 @@ interface TerminalViewProps {
|
||||
* so it is moved out of the viewport with a transform, which keeps its
|
||||
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||
covered?: boolean;
|
||||
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
|
||||
* as its bare path and any other file as an `@` mention (`claudePastes`),
|
||||
* any other tool its plain path. */
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
|
||||
@@ -170,6 +185,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
onExit,
|
||||
autoFocus = true,
|
||||
covered = false,
|
||||
tool,
|
||||
}: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
@@ -193,6 +209,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||
const [limitMb, setLimitMb] = useState(0);
|
||||
// The socket dropped after it had been up: keystrokes are not reaching tmux.
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
|
||||
@@ -207,10 +225,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
const flash = (state: DropState, ms = 2500) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), ms);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -227,15 +245,18 @@ export const TerminalView = memo(function TerminalView({
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDrop(null);
|
||||
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
|
||||
const text = e.dataTransfer.getData("text/plain");
|
||||
if (text) termRef.current?.paste(text + " ");
|
||||
if (local) pastePaths(local);
|
||||
else if (text) termRef.current?.paste(text + " ");
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
void attachFiles(Array.from(e.dataTransfer.files));
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
void attachFiles(files, localCandidates(e.dataTransfer, files));
|
||||
};
|
||||
|
||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||
@@ -247,26 +268,60 @@ export const TerminalView = memo(function TerminalView({
|
||||
const ctrlV = ctrlVRef.current;
|
||||
ctrlVRef.current = false;
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
// A file manager's copied files reach a browser as `file://` text only.
|
||||
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
|
||||
if (local) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pastePaths(local);
|
||||
return;
|
||||
}
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (files.length > 0) void attachFiles(files);
|
||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
||||
else termRef.current?.input("\x16", true);
|
||||
};
|
||||
|
||||
const attachFiles = async (files: File[]) => {
|
||||
/** Pastes files' paths as a local terminal hands them over — paste(), not
|
||||
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
|
||||
* rather than typed keystrokes. A `claude` pane gets its images as bare
|
||||
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
|
||||
* (`claudePastes`). */
|
||||
const pastePaths = (paths: string[]) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
|
||||
term.paste(text + " ");
|
||||
term.focus();
|
||||
};
|
||||
|
||||
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||
* server finds the same files there — nothing uploaded —, else the paths
|
||||
* the uploads were saved at. A refused file does not cost the others. */
|
||||
const attachFiles = async (files: File[], local: string[] | null = null) => {
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
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(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
||||
const errors: unknown[] = [];
|
||||
if (!paths) {
|
||||
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
||||
paths = results.flatMap((r) => {
|
||||
if (r.status === "fulfilled") return [r.value.path];
|
||||
errors.push(r.reason);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
if (paths.length) pastePaths(paths);
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
.find((e) => e?.code === "ETOOBIG");
|
||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||
if (!errors.length) setDrop(null);
|
||||
else if (!tooBig) flash("failed");
|
||||
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -554,6 +609,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
}
|
||||
}
|
||||
term.write(data);
|
||||
notePaneOutput(runId);
|
||||
}
|
||||
};
|
||||
sock.onclose = (e?: CloseEvent) => {
|
||||
@@ -716,6 +772,17 @@ export const TerminalView = memo(function TerminalView({
|
||||
rows.push(row);
|
||||
}
|
||||
return { rows, cols: term.cols };
|
||||
},
|
||||
// Plain text of the rows on screen, dim cells kept (a status line's
|
||||
// `esc to interrupt` is dim): the chat's busy read.
|
||||
() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return [];
|
||||
const buf = term.buffer.active;
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < term.rows; y++)
|
||||
rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? "");
|
||||
return rows;
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
@@ -872,10 +939,12 @@ export const TerminalView = memo(function TerminalView({
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
{t(`drop.${drop}`, { mb: limitMb })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* @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.
|
||||
* (files only, none missing), this browser's Recent uploads and, for a
|
||||
* browser on the dashboard's machine, a file chosen in the OS's own picker.
|
||||
* @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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ChatAddMenu } from "../ChatAddMenu";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -21,6 +22,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||
lanes: {
|
||||
pickPath: vi.fn().mockResolvedValue({ path: "/home/u/Videos/demo clip.mp4" }),
|
||||
sources: vi.fn().mockResolvedValue({
|
||||
sources: [
|
||||
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||
@@ -61,6 +63,28 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ChatAddMenu — a file on this computer", () => {
|
||||
it("attaches the file the OS picker chose by its path, uploading nothing", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.click(screen.getByTestId("chat-add-pick-local"));
|
||||
expect(api.lanes.pickPath).toHaveBeenCalledWith("file", "/repo");
|
||||
await vi.waitFor(() =>
|
||||
expect(onAttach).toHaveBeenCalledWith([
|
||||
{ path: '@"/home/u/Videos/demo clip.mp4"', name: "demo clip.mp4" },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("is not offered to a browser on another machine", () => {
|
||||
// Only a loopback address: a name that merely starts with "localhost" is not one.
|
||||
vi.stubGlobal("location", { ...window.location, hostname: "localhost.example.com" });
|
||||
renderMenu();
|
||||
expect(screen.queryByTestId("chat-add-pick-local")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatAddMenu — existing resources", () => {
|
||||
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||
const onAttach = renderMenu();
|
||||
|
||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
artifacts: {
|
||||
pinFile: vi.fn().mockResolvedValue({ item: { id: 1 } }),
|
||||
uploadFile: vi.fn().mockResolvedValue({ item: { id: 2 } }),
|
||||
},
|
||||
sessions: {
|
||||
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
||||
readFile: vi.fn((_id: string, p: string) =>
|
||||
@@ -75,7 +79,11 @@ 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(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
});
|
||||
|
||||
@@ -89,7 +97,11 @@ describe("FilePreview", () => {
|
||||
it("reads Windows paths the same way", async () => {
|
||||
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
cleanup();
|
||||
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||
@@ -144,7 +156,11 @@ describe("FileListModal", () => {
|
||||
);
|
||||
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();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,3 +188,77 @@ describe("SkillListModal", () => {
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the artifact view", () => {
|
||||
const html = (lines: string[]) =>
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValueOnce({
|
||||
available: true,
|
||||
path: "page.html",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines,
|
||||
} as never);
|
||||
|
||||
it("runs a page in a sandbox with scripts but no same origin, and shows its code", async () => {
|
||||
html(["<button>go</button>"]);
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||
const frame = (await screen.findByTestId("artifact-frame")) as HTMLIFrameElement;
|
||||
expect(frame.getAttribute("sandbox")).toContain("allow-scripts");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<button>go</button>");
|
||||
fireEvent.click(screen.getByTestId("artifact-tab-code"));
|
||||
expect(screen.queryByTestId("artifact-frame")).toBeNull();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("<button>go</button>")
|
||||
)
|
||||
).toBeTruthy();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("steps back to an older version the conversation wrote", async () => {
|
||||
html(["<p>two</p>"]);
|
||||
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
|
||||
type: "assistant" as const,
|
||||
timestamp: null,
|
||||
content: [{ type: "tool_use" as const, id, name, input }],
|
||||
});
|
||||
const messages = [
|
||||
tool("1", { file_path: "/w/page.html", content: "<p>one</p>" }),
|
||||
tool("2", { file_path: "/w/page.html", old_string: "one", new_string: "two" }, "Edit"),
|
||||
];
|
||||
render(
|
||||
<FilePreview
|
||||
path="/w/page.html"
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
messages={messages as never}
|
||||
/>
|
||||
);
|
||||
const versions = await screen.findByTestId("artifact-versions");
|
||||
expect(versions.textContent).toContain("2");
|
||||
expect(
|
||||
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
||||
).toContain("two");
|
||||
fireEvent.click(versions.querySelector("button")!);
|
||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("saves a workspace file to the Artifacts library by its path", async () => {
|
||||
html(["<p>x</p>"]);
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||
await screen.findByTestId("artifact-frame");
|
||||
fireEvent.click(screen.getByTestId("artifact-share"));
|
||||
await vi.waitFor(() =>
|
||||
expect(api.artifacts.pinFile).toHaveBeenCalledWith({
|
||||
laneId: 4,
|
||||
path: "page.html",
|
||||
title: "page.html",
|
||||
sessionId: "s1",
|
||||
})
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
* activity panel, collapsed into a file button with the output count, whose
|
||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||
* Stop in Send's place while the main agent works, pressing Esc in the pane;
|
||||
* Stop in Send's place while the main agent works (its hooks, or the pane's
|
||||
* own busy line / fresh output), pressing Esc in the pane;
|
||||
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
||||
* message sent meanwhile typed as its "Type something" answer.
|
||||
* message sent meanwhile typed as its "Type something" answer; a Codex
|
||||
* terminal's Model / Permission mode buttons typing its own pickers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,11 +24,14 @@ import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
onPaneOutput,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../../lib/panePaste";
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
@@ -51,8 +56,12 @@ vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: vi.fn(() => true),
|
||||
readRunMenu: vi.fn(() => null),
|
||||
readRunBusy: vi.fn(() => false),
|
||||
chooseRunOption: vi.fn(() => true),
|
||||
answerRunMenu: vi.fn(() => true),
|
||||
stepRunMenu: vi.fn(() => true),
|
||||
notePaneFocus: vi.fn(),
|
||||
onPaneOutput: vi.fn(() => () => {}),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -212,6 +221,54 @@ describe("PaneChat — Stop", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
|
||||
// omp, Cursor or Codex without its hooks: only the terminal knows.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||
try {
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
expect(readRunBusy).toHaveBeenCalledWith("agenthub-lane-1", "claude");
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||
const fns: (() => void)[] = [];
|
||||
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||
fns.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
try {
|
||||
renderChat();
|
||||
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||
fns.forEach((fn) => fn());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
// Quiet for longer than OUTPUT_BUSY_MS: at rest again.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2800);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -430,7 +487,7 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||
});
|
||||
|
||||
@@ -462,7 +519,9 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith(
|
||||
"agenthub-lane-1",
|
||||
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
|
||||
// A trailing space: an @ mention at the end would leave claude's file
|
||||
// suggester open, and it takes the Enter.
|
||||
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
|
||||
[]
|
||||
);
|
||||
});
|
||||
@@ -551,4 +610,103 @@ describe("PaneChat — a menu the CLI asks with", () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-reads its transcript when the pane draws, at most once a second", async () => {
|
||||
// Every listener the chat put on the pane (the re-read, the busy clock).
|
||||
const fns: (() => void)[] = [];
|
||||
const drew = () => fns.forEach((fn) => fn());
|
||||
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||
fns.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const off = eventBus.subscribe((m) => {
|
||||
if (m.type === "transcript_changed") seen.push(m.data);
|
||||
});
|
||||
try {
|
||||
renderChat();
|
||||
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||
vi.useFakeTimers();
|
||||
drew();
|
||||
drew();
|
||||
drew();
|
||||
expect(seen).toEqual([{ session_id: "s1" }]);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
});
|
||||
expect(seen).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
off();
|
||||
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||
}
|
||||
});
|
||||
|
||||
const PERMISSION: PaneMenu = {
|
||||
question: "Do you want to proceed?",
|
||||
detail: ["Bash command", "npm test", "Run the tests"],
|
||||
options: [
|
||||
{ n: 1, label: "Yes", hint: "", checked: null },
|
||||
{ n: 2, label: "Yes, and don't ask again for npm test", hint: "", checked: null },
|
||||
{ n: 3, label: "No, and tell Claude what to do differently (esc)", hint: "", checked: null },
|
||||
],
|
||||
cursor: 0,
|
||||
multi: false,
|
||||
typeAt: -1,
|
||||
};
|
||||
|
||||
it("lays a permission prompt out as a card, each answer its own key", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
const card = await screen.findByTestId("pane-chat-permission");
|
||||
expect(card).toHaveTextContent("Bash command");
|
||||
expect(card).toHaveTextContent("npm test");
|
||||
expect(card).toHaveTextContent("Do you want to proceed?");
|
||||
const options = screen.getAllByTestId("pane-chat-permission-option");
|
||||
// The key hint is dropped from the label.
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
"Yes",
|
||||
"Yes, and don't ask again for npm test",
|
||||
"No, and tell Claude what to do differently",
|
||||
]);
|
||||
fireEvent.click(options[1]!);
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 1);
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("denies with a note: picks the No row, then sends the note as the next message", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
fireEvent.click(await screen.findByTestId("pane-chat-permission-tell"));
|
||||
const field = screen.getByTestId("pane-chat-permission-note");
|
||||
fireEvent.change(field, { target: { value: "use pnpm" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 2);
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||
expect(vi.mocked(submitToRun).mock.calls[0]![1]).toContain("use pnpm");
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — a Codex terminal", () => {
|
||||
it("types /model and /permissions, whose pickers come back as menu cards", () => {
|
||||
renderChat({ tool: "codex" });
|
||||
fireEvent.click(screen.getByTestId("pane-chat-codex-model"));
|
||||
fireEvent.click(screen.getByTestId("pane-chat-codex-permissions"));
|
||||
expect(vi.mocked(submitToRun).mock.calls.map((c) => c[1])).toEqual(["/model", "/permissions"]);
|
||||
});
|
||||
|
||||
it("offers neither to a Claude pane", () => {
|
||||
renderChat();
|
||||
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,11 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||
run: {
|
||||
files: r({ items: [] }),
|
||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||
localPaths: r({ sizes: [1] }),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({
|
||||
items: [
|
||||
@@ -342,6 +346,120 @@ describe("PromptEditor — newlines and images", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a local file manager's files by their own paths, uploading nothing", async () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||
const data: Record<string, string> = { "text/uri-list": "file:///home/u/demo.mp4" };
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
expect(await screen.findByText("demo.mp4")).toBeTruthy();
|
||||
expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]);
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
||||
});
|
||||
|
||||
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
||||
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
||||
const onImagesChange = vi.fn();
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={onImagesChange}
|
||||
/>
|
||||
);
|
||||
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||
const data: Record<string, string> = { "text/uri-list": "file:///Users/me/spec.pdf" };
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined));
|
||||
});
|
||||
|
||||
it("keeps the files that uploaded when another one is refused", async () => {
|
||||
vi.mocked(api.run.dropFile)
|
||||
.mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" })
|
||||
.mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] },
|
||||
});
|
||||
expect(await screen.findByText("ok.txt")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt");
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/too large/);
|
||||
// Anything attached afterwards — here a Files panel row — ends the error.
|
||||
const data: Record<string, string> = {
|
||||
[ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }),
|
||||
};
|
||||
fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } });
|
||||
expect(await screen.findByText("a.ts")).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a file over the server's limit is too big, and how to attach it", async () => {
|
||||
vi.mocked(api.run.dropFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 })
|
||||
);
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } });
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/too large to upload \(max 20 MB\).*Choose a file on this computer/
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -593,7 +711,10 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
// A Windows path inside a paste of text stayed a plain path in claude; a
|
||||
// paste that is only the path becomes [Image #n].
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{
|
||||
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
name: "image.png",
|
||||
},
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
|
||||
@@ -606,4 +727,29 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
|
||||
// A bare pasted path stayed plain text on Windows, and a path in the
|
||||
// command-line prompt is never an image; claude reads an @ mention at submit.
|
||||
const images: PromptImage[] = [
|
||||
{
|
||||
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
name: "image.png",
|
||||
},
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
|
||||
];
|
||||
expect(promptForPane(" look ", images, true)).toEqual({
|
||||
text:
|
||||
"look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " +
|
||||
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
|
||||
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
|
||||
files: [],
|
||||
});
|
||||
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
|
||||
expect(promptWithImages("see", [images[1]!], true)).toBe(
|
||||
"see @/data/drops/0123456789abcdef-log.txt"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,8 +26,14 @@ const inputMock = vi.fn();
|
||||
// The newest terminal's hidden input, where the browser's IME types.
|
||||
let textarea: HTMLTextAreaElement;
|
||||
const dropFileMock = vi.fn();
|
||||
const localPathsMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||
},
|
||||
},
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -512,6 +518,23 @@ describe("TerminalView", () => {
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("pastes a local file manager's own paths, uploading nothing", async () => {
|
||||
dropFileMock.mockReset();
|
||||
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||
const data: Record<string, string> = {
|
||||
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
|
||||
};
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
|
||||
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pastes every path of several dropped files, images or not", async () => {
|
||||
dropFileMock
|
||||
.mockReset()
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
* @description Settings → Account (server mode, any signed-in role): the
|
||||
* account's own information (`GET /api/me/account` — username, role, member
|
||||
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
|
||||
* sidebar shows it at once), its display name
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
|
||||
* an https link the browser loads itself, `POST /api/me/avatar/url`; the sidebar
|
||||
* shows it at once), its display name
|
||||
* and email (`PATCH /api/me/account`), a password change that asks for the
|
||||
* current one (`POST /api/me/password` — this browser stays signed in, every
|
||||
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
|
||||
* typed (`POST /api/me/password` — this browser stays signed in, every
|
||||
* other sign-in ends) and "Sign out other devices" (`POST
|
||||
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
|
||||
* admin's to change, on the Users page — never here.
|
||||
@@ -23,15 +25,18 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Camera, Link2, LogOut, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type MyAccount } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { ROLE_BADGE } from "../users/UserEditDialog";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { PasswordRules } from "../users/PasswordRules";
|
||||
import { passwordProblems } from "../../lib/passwordRules";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
|
||||
@@ -43,6 +48,24 @@ const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unkno
|
||||
: err.message;
|
||||
};
|
||||
|
||||
/** Whether `url` loads as an image embedded in this page — the way every
|
||||
* avatar is shown: no referrer, the cookies a cross-site image gets. */
|
||||
export function loadsAsImage(url: string, timeoutMs = 10_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const done = (ok: boolean) => {
|
||||
window.clearTimeout(timer);
|
||||
img.onload = img.onerror = null;
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = window.setTimeout(() => done(false), timeoutMs);
|
||||
img.referrerPolicy = "no-referrer";
|
||||
img.onload = () => done(img.naturalWidth > 0);
|
||||
img.onerror = () => done(false);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function AccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [account, setAccount] = useState<ServerAccount | null>(null);
|
||||
@@ -64,19 +87,23 @@ export function AccountSettings() {
|
||||
);
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null);
|
||||
api.me
|
||||
return api.me
|
||||
.account()
|
||||
.then(apply)
|
||||
.catch((e: Error) => setLoadError(e.message));
|
||||
}, [apply]);
|
||||
useEffect(load, [load]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
const [retrying, retry] = useSpin(load);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
|
||||
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
|
||||
<button type="button" className="btn-ghost text-xs" onClick={load}>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
|
||||
<button type="button" className="btn-ghost text-xs" onClick={retry} disabled={retrying}>
|
||||
<RefreshCw className={`h-3.5 w-3.5${retrying ? " animate-spin" : ""}`} />{" "}
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -110,18 +137,35 @@ function ProfileCard({
|
||||
const shown = user.displayName || user.username;
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// null = the link field is closed.
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
|
||||
const run = async (op: () => Promise<MyAccount>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onSaved(await op());
|
||||
toast.success(done);
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(errText(t, e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const applyLink = async () => {
|
||||
const url = link?.trim();
|
||||
if (!url) return;
|
||||
// The avatar routes only redirect to the link, so it shows only if THIS
|
||||
// browser can load it as an embedded image: a link behind a login (a
|
||||
// Mattermost /api/v4/users/<id>/image) gets no cookie from another site
|
||||
// and fails — say so now instead of saving a picture nobody sees.
|
||||
setBusy(true);
|
||||
const ok = await loadsAsImage(url);
|
||||
setBusy(false);
|
||||
if (!ok) return void toast.error(t("account.avatar.linkUnreachable"));
|
||||
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
|
||||
};
|
||||
const pick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // the same file can be picked again
|
||||
@@ -150,6 +194,18 @@ function ProfileCard({
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-ghost h-6 w-6 justify-center p-0 ${link !== null ? "text-accent" : ""}`}
|
||||
onClick={() => setLink((v) => (v === null ? "" : null))}
|
||||
disabled={busy}
|
||||
title={t("account.avatar.fromUrl")}
|
||||
aria-label={t("account.avatar.fromUrl")}
|
||||
aria-pressed={link !== null}
|
||||
data-testid="account-avatar-url-toggle"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{user.avatarAt && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -187,6 +243,36 @@ function ProfileCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{link !== null && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
className="input w-full text-sm"
|
||||
value={link}
|
||||
placeholder={t("account.avatar.urlPlaceholder")}
|
||||
aria-label={t("account.avatar.fromUrl")}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void applyLink();
|
||||
} else if (e.key === "Escape") setLink(null);
|
||||
}}
|
||||
data-testid="account-avatar-url-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex-shrink-0 text-sm"
|
||||
onClick={() => void applyLink()}
|
||||
disabled={busy || !link.trim()}
|
||||
data-testid="account-avatar-url-apply"
|
||||
>
|
||||
{t("account.avatar.urlApply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
|
||||
<Stat label={t("account.memberSince")}>
|
||||
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
|
||||
@@ -283,13 +369,17 @@ function ProfileForm({
|
||||
|
||||
function PasswordForm() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { state } = useAuth();
|
||||
const username = state.mode === "server" ? state.user?.username : undefined;
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mismatch = again.length > 0 && again !== next;
|
||||
const ready = current.length > 0 && next.length > 0 && again === next && !busy;
|
||||
// The account's own password: every rule (server/lib/auth.js decides too).
|
||||
const strong = passwordProblems(next, username).length === 0;
|
||||
const ready = current.length > 0 && strong && again === next && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -331,7 +421,7 @@ function PasswordForm() {
|
||||
aria-label={t("account.currentPassword")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
|
||||
<Field label={t("account.newPassword")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
@@ -352,6 +442,9 @@ function PasswordForm() {
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<PasswordRules password={next} username={username} />
|
||||
</div>
|
||||
{(mismatch || error) && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error ?? t("account.mismatch")}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FileUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AgentInstructionsView } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
@@ -41,13 +42,16 @@ export function AgentInstructionsSettings() {
|
||||
|
||||
const load = () => {
|
||||
setLoadError(null);
|
||||
api.me
|
||||
return api.me
|
||||
.instructions()
|
||||
.then(apply)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
const [retrying, retry] = useSpin(load);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
@@ -123,7 +127,12 @@ export function AgentInstructionsSettings() {
|
||||
<span className="truncate">
|
||||
{t("agentInstructions.loadFailed", { message: loadError })}
|
||||
</span>
|
||||
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
onClick={retry}
|
||||
disabled={retrying}
|
||||
>
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
@@ -374,9 +375,7 @@ export function AiToolsSettings({
|
||||
{ctx.menu.item.path && (
|
||||
<>
|
||||
{isAdmin && <MenuSeparator />}
|
||||
<MenuItem
|
||||
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
||||
>
|
||||
<MenuItem onSelect={pick((tool) => void copyText(tool.path ?? ""))}>
|
||||
{t("rowMenu.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`); then the
|
||||
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
||||
* default) or Raw (`lib/markdownView.ts`, `#markdown-view`); then the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
@@ -97,6 +99,7 @@ export function AppearanceSettings() {
|
||||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
@@ -224,6 +227,25 @@ export function AppearanceSettings() {
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
<SettingsRow
|
||||
id="markdown-view"
|
||||
label={t("markdownView.label")}
|
||||
description={t("markdownView.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("markdownView.label")}
|
||||
value={mdPreview ? "preview" : "raw"}
|
||||
onChange={(v) => {
|
||||
writeMarkdownPreview(v === "preview");
|
||||
setMdPreview(v === "preview");
|
||||
}}
|
||||
options={[
|
||||
{ value: "preview" as const, label: t("markdownView.preview") },
|
||||
{ value: "raw" as const, label: t("markdownView.raw") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file OmpSharingSettings — Settings → AI tools → Oh My Pi login sharing: the
|
||||
* admin's server-wide switch that lets every member's omp pane run on the
|
||||
* HOST account's omp login through omp's own auth broker + gateway, without
|
||||
* any member holding a credential (`GET/PUT /api/ai-tools/omp-sharing`,
|
||||
* `server/lib/omp-share.js`). Renders nothing outside server mode with
|
||||
* per-user accounts (S3), or for anyone but an admin.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type OmpSharingState } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function OmpSharingSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const [state, setState] = useState<OmpSharingState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (local || !isAdmin) return;
|
||||
api.aiTools
|
||||
.ompSharing()
|
||||
.then(setState)
|
||||
.catch(() => setState(null));
|
||||
}, [local, isAdmin]);
|
||||
|
||||
if (local || !isAdmin || !state?.available) return null;
|
||||
const error = state.broker.error || state.gateway.error;
|
||||
const status = !state.enabled
|
||||
? null
|
||||
: !state.installed
|
||||
? t("ompSharing.notInstalled")
|
||||
: state.broker.running && state.gateway.running
|
||||
? t("ompSharing.running")
|
||||
: error
|
||||
? t("ompSharing.failed", { error })
|
||||
: t("ompSharing.starting");
|
||||
return (
|
||||
<SettingsSection id="omp-sharing" title={t("ompSharing.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("ompSharing.switch")}
|
||||
description={t("ompSharing.hint")}
|
||||
checked={state.enabled}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await api.aiTools.setOmpSharing(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{status && (
|
||||
<p data-testid="omp-sharing-status" className="text-xs text-fg-muted">
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,7 @@ import { api } from "../../lib/api";
|
||||
import type { PushTokenStatus } from "../../lib/api";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
|
||||
function forwarder(origin: string) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
|
||||
* account's own starting choices for a new Claude Code session (permission
|
||||
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
|
||||
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
|
||||
* synced per account). Turning bypass on as a default asks first; a pick in
|
||||
* the prompt box still changes only that one session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import type { CodexApproval, CodexSandbox } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import {
|
||||
CODEX_APPROVALS,
|
||||
CODEX_SANDBOXES,
|
||||
DEFAULT_MODES,
|
||||
readCodexDefaults,
|
||||
readDefaultMode,
|
||||
writeCodexDefaults,
|
||||
writeDefaultMode,
|
||||
type DefaultMode,
|
||||
} from "../../lib/sessionDefaults";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
const MODE_KEYS: Record<DefaultMode, string> = {
|
||||
auto: "Auto",
|
||||
acceptEdits: "AcceptEdits",
|
||||
default: "Default",
|
||||
plan: "Plan",
|
||||
bypassPermissions: "Bypass",
|
||||
};
|
||||
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
|
||||
"read-only": "sandboxReadOnly",
|
||||
"workspace-write": "sandboxWorkspaceWrite",
|
||||
"danger-full-access": "sandboxFullAccess",
|
||||
};
|
||||
const APPROVAL_KEYS: Record<CodexApproval, string> = {
|
||||
"on-request": "approvalOnRequest",
|
||||
never: "approvalNever",
|
||||
};
|
||||
|
||||
function Danger({ children }: { children: string }) {
|
||||
return (
|
||||
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionDefaultsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
|
||||
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
|
||||
const [codex, setCodex] = useState(readCodexDefaults);
|
||||
|
||||
const askBypass = (tool: string) =>
|
||||
confirmDialog({
|
||||
title:
|
||||
tool === "Codex"
|
||||
? t("sessionDefaults.codexConfirmTitle")
|
||||
: t("sessionDefaults.confirmTitle", { tool }),
|
||||
message: t("sessionDefaults.confirmBody", { tool }),
|
||||
confirmLabel: t("sessionDefaults.confirmLabel"),
|
||||
});
|
||||
|
||||
const pickMode = async (m: DefaultMode) => {
|
||||
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
|
||||
writeDefaultMode(m);
|
||||
setMode(m);
|
||||
};
|
||||
const patchCodex = (patch: Partial<typeof codex>) => {
|
||||
writeCodexDefaults(patch);
|
||||
setCodex((c) => ({ ...c, ...patch }));
|
||||
};
|
||||
const pickCodexBypass = async (on: boolean) => {
|
||||
if (on && !(await askBypass("Codex"))) return;
|
||||
patchCodex({ bypass: on });
|
||||
};
|
||||
|
||||
if (!shown("claude") && !shown("codex")) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="session-defaults"
|
||||
title={t("sessionDefaults.title")}
|
||||
description={t("sessionDefaults.description")}
|
||||
>
|
||||
{shown("claude") && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.claudeMode")}
|
||||
description={t("sessionDefaults.claudeModeDesc")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.claudeMode")}
|
||||
value={mode}
|
||||
onChange={(m) => void pickMode(m)}
|
||||
options={DEFAULT_MODES.map((m) => ({
|
||||
value: m,
|
||||
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
|
||||
</>
|
||||
)}
|
||||
{shown("codex") && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.codexSandbox")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.codexSandbox")}
|
||||
value={codex.sandbox}
|
||||
onChange={(sandbox) => patchCodex({ sandbox })}
|
||||
options={CODEX_SANDBOXES.map((v) => ({
|
||||
value: v,
|
||||
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.codexApproval")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.codexApproval")}
|
||||
value={codex.approval}
|
||||
onChange={(approval) => patchCodex({ approval })}
|
||||
options={CODEX_APPROVALS.map((v) => ({
|
||||
value: v,
|
||||
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={t("sessionDefaults.codexBypass")}
|
||||
description={t("sessionDefaults.codexBypassDesc")}
|
||||
checked={codex.bypass}
|
||||
onChange={(on) => void pickCodexBypass(on)}
|
||||
/>
|
||||
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import { CopyButton } from "../event-views/primitives";
|
||||
import { snapshotUrl } from "../SnapshotButton";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
export function SnapshotSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -116,9 +117,7 @@ export function SnapshotSettings() {
|
||||
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
|
||||
{t("snapshots.open")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
|
||||
>
|
||||
<MenuItem onSelect={pick((s) => void copyText(snapshotUrl(s.token)))}>
|
||||
{t("snapshots.copyLink")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
/**
|
||||
* @file ToolProfilesSettings — Settings → AI tools → Profiles: every AI tool's
|
||||
* login profiles in ONE place, a tab per tool. Claude Code's are cc-profiles
|
||||
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
|
||||
* warning — this panel used to be a modal on the Claude Code page); Codex and
|
||||
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
|
||||
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
|
||||
* each Claude/Codex profile to the account pool in Administration → Users,
|
||||
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
|
||||
* pool's Add dialog on it), and shows how many accounts the pool holds.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRightLeft,
|
||||
Check,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type CcAccountIdentity,
|
||||
type CcProfile,
|
||||
type CcProfileUsage,
|
||||
type CcUsageWindow,
|
||||
type ToolProfile,
|
||||
type ToolProfileIdentity,
|
||||
type ToolProfilesTool,
|
||||
} from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { SkeletonRows } from "../config/configPrimitives";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in. */
|
||||
const TOOLS: { id: string; label: string; login: string }[] = [
|
||||
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
||||
{ id: "codex", label: "Codex", login: "codex login" },
|
||||
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
||||
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
||||
];
|
||||
/** Tools the account pool takes (server/routes/accounts.js). */
|
||||
const POOL_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
|
||||
const btnCls =
|
||||
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
|
||||
|
||||
export function ToolProfilesSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const { local, seesAll } = usePermissions();
|
||||
const shown = TOOLS.filter((x) => {
|
||||
const tool = aiTools.byId(x.id);
|
||||
if (x.id === "claude") return tool?.enabled !== false;
|
||||
return !!tool && tool.installed && tool.enabled !== false;
|
||||
});
|
||||
const [tab, setTab] = useState<string>("claude");
|
||||
const current = shown.find((x) => x.id === tab) ?? shown[0];
|
||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
|
||||
const loadOthers = useCallback(
|
||||
() =>
|
||||
api.toolProfiles
|
||||
.list()
|
||||
.then((r) => setOthers(r.tools))
|
||||
.catch(() => setOthers([])),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadOthers();
|
||||
}, [loadOthers]);
|
||||
// The pool is a server-mode, staff-only read.
|
||||
useEffect(() => {
|
||||
if (local || !seesAll) return;
|
||||
api.accountPool
|
||||
.list()
|
||||
.then(setPool)
|
||||
.catch(() => setPool(null));
|
||||
}, [local, seesAll]);
|
||||
|
||||
if (!current) return null;
|
||||
const poolCount = (tool: string) =>
|
||||
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="tool-profiles"
|
||||
title={t("toolProfiles.title")}
|
||||
description={t("toolProfiles.description")}
|
||||
>
|
||||
<div role="tablist" aria-label={t("toolProfiles.title")} className="flex flex-wrap gap-1.5">
|
||||
{shown.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={x.id === current.id}
|
||||
onClick={() => setTab(x.id)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs ${
|
||||
x.id === current.id
|
||||
? "border-accent/40 bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={{ id: x.id, label: x.label }} className="h-3.5 w-3.5" />
|
||||
{x.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="mt-3 space-y-3">
|
||||
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
<PoolLine count={poolCount(current.id)!} />
|
||||
)}
|
||||
{current.id === "claude" ? (
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
||||
) : (
|
||||
<OtherProfiles
|
||||
key={current.id}
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
login={current.login}
|
||||
state={others?.find((o) => o.tool === current.id) ?? null}
|
||||
loading={others === null}
|
||||
pooled={!local && POOL_TOOLS.has(current.id)}
|
||||
onChanged={loadOthers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** "The pool holds N accounts" + the way to it (Administration → Users). */
|
||||
function PoolLine({ count }: { count: number }) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-fg-secondary">
|
||||
<KeyRound className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="flex-1">{t("toolProfiles.poolCount", { count })}</span>
|
||||
<Link to="/admin#account-pool" className="text-accent hover:underline">
|
||||
{t("toolProfiles.openPool")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The link that opens the pool's Add dialog on one saved profile (admins only). */
|
||||
function MoveToPool({ tool, name }: { tool: string; name: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin } = usePermissions();
|
||||
if (!isAdmin) return null;
|
||||
return (
|
||||
<Link
|
||||
to={`/admin?${new URLSearchParams({ poolAdd: tool, poolProfile: name })}#account-pool`}
|
||||
title={t("toolProfiles.moveToPoolHint")}
|
||||
className="h-7 text-[11px] px-2 rounded-md text-fg-muted hover:text-accent hover:bg-surface-3 inline-flex items-center gap-1"
|
||||
>
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
{t("toolProfiles.moveToPool")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginHint({ text, command }: { text: string; command: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] text-fg-muted mb-1">{text}</div>
|
||||
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
|
||||
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await copyText(command, true)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
|
||||
title={t("snippet.copy")}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A profile's label, renamed in place: click it (or the menu's Rename), type, Enter. */
|
||||
function LabelEdit({
|
||||
label,
|
||||
busy,
|
||||
editing,
|
||||
setEditing,
|
||||
onRename,
|
||||
}: {
|
||||
label: string;
|
||||
busy: boolean;
|
||||
editing: boolean;
|
||||
setEditing: (on: boolean) => void;
|
||||
onRename: (next: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [draft, setDraft] = useState(label);
|
||||
useEffect(() => {
|
||||
if (editing) setDraft(label);
|
||||
}, [editing, label]);
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => setEditing(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
if (e.key !== "Enter") return;
|
||||
const next = draft.trim();
|
||||
setEditing(false);
|
||||
if (next && next !== label) onRename(next);
|
||||
}}
|
||||
maxLength={60}
|
||||
aria-label={t("profiles.renameLabel")}
|
||||
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => setEditing(true)}
|
||||
title={t("profiles.renameHint")}
|
||||
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||
>
|
||||
{label}
|
||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One mutation with a busy key, a toast either way, then a reload. */
|
||||
function useRunner(load: () => Promise<unknown>) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(success);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[load]
|
||||
);
|
||||
return { busy, run };
|
||||
}
|
||||
|
||||
// ── Codex, Oh My Pi, … ─────────────────────────────────────────────────
|
||||
|
||||
function OtherProfiles({
|
||||
tool,
|
||||
label,
|
||||
login,
|
||||
state,
|
||||
loading,
|
||||
pooled,
|
||||
onChanged,
|
||||
}: {
|
||||
tool: string;
|
||||
label: string;
|
||||
login: string;
|
||||
state: ToolProfilesTool | null;
|
||||
loading: boolean;
|
||||
pooled: boolean;
|
||||
onChanged: () => Promise<unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t: tc } = useTranslation("ccConfig");
|
||||
const { busy, run } = useRunner(onChanged);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
|
||||
if (loading) return <SkeletonRows n={2} />;
|
||||
if (!state) return <p className="text-xs text-fg-muted">{t("toolProfiles.noProfiles")}</p>;
|
||||
if (!state.supported) {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted inline-flex items-start gap-1.5">
|
||||
<ShieldAlert className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t(`toolProfiles.unsupported.${tool}`, { defaultValue: state.reason ?? "" })}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const items = state.items;
|
||||
const activeSaved = items.some((p) => p.active);
|
||||
const remove = async (p: ToolProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
message: t("toolProfiles.deleteConfirmBody", { tool: label }),
|
||||
confirmLabel: tc("profiles.deleteButton"),
|
||||
});
|
||||
if (ok) {
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.toolProfiles.remove(tool, p.name),
|
||||
tc("profiles.deleteSuccess", { name: p.label })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">
|
||||
{t(`toolProfiles.subtitle.${tool}`, { defaultValue: "" })}
|
||||
</p>
|
||||
{state.error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{state.error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className={`${rowCls} space-y-1`}>
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{tc("profiles.activeLabel")}
|
||||
</div>
|
||||
{state.active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">{who(state.active)}</div>
|
||||
<OtherIdentityLine identity={state.active} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("toolProfiles.noActive", { tool: label })}</div>
|
||||
)}
|
||||
{state.active && !activeSaved && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{tc("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run("save", () => api.toolProfiles.save(tool), tc("profiles.saveSuccess"))
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{busy === "save" ? tc("profiles.saving") : t("toolProfiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||
{tc("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items.map((p) => (
|
||||
<div key={p.name} data-testid={`profile-row-${tool}-${p.name}`} className={rowCls}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.toolProfiles.rename(tool, p.name, next),
|
||||
tc("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.active && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">{who(p)}</span>
|
||||
{!p.active && (
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.toolProfiles.activate(tool, p.name),
|
||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
||||
)
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
{busy === `switch:${p.name}`
|
||||
? tc("profiles.switching")
|
||||
: tc("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool={tool} name={p.name} />}
|
||||
{!p.active && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={tc("profiles.deleteButton")}
|
||||
aria-label={tc("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<OtherIdentityLine identity={p} />
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{tc("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const who = (id: ToolProfileIdentity) => id.email || id.detail || id.kind;
|
||||
|
||||
function OtherIdentityLine({ identity }: { identity: ToolProfileIdentity }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const parts = [
|
||||
t(`toolProfiles.kind.${identity.kind}`, { defaultValue: identity.kind }),
|
||||
identity.email ? identity.detail : null,
|
||||
].filter(Boolean);
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
function ActiveBadge() {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||
{t("profiles.activeBadge")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Claude Code ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
|
||||
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
|
||||
// subagents, memory, plugins, marketplaces and settings all stay exactly where
|
||||
// they are. The dashboard cannot log a new account in (only `claude auth login`
|
||||
// can mint tokens), so adding a profile is always "log in, then snapshot what
|
||||
// the CLI wrote" — a save button on the live account, never a name field: the
|
||||
// server derives the identifier, and the user edits the label in place. The
|
||||
// save button only appears while the live account is unsaved, because one
|
||||
// account is one profile.
|
||||
|
||||
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
|
||||
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Quota per profile, keyed by `name`, fetched for every profile once the
|
||||
* list arrives: the live account is read from the CLI and never refreshed,
|
||||
* any other profile only when its token has actually expired. */
|
||||
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||
/** Per profile, so one slow account does not freeze the other rows. */
|
||||
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
const ctx = useContextMenu<CcProfile>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.ccConfig.profiles();
|
||||
setItems(r.items);
|
||||
setActive(r.active);
|
||||
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "unknown");
|
||||
}
|
||||
}, []);
|
||||
const { busy, run } = useRunner(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const checkUsage = useCallback(async (name: string) => {
|
||||
setUsageBusy((prev) => ({ ...prev, [name]: true }));
|
||||
setUsageError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.ccConfig.profileUsage(name);
|
||||
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||
} catch (err: unknown) {
|
||||
setUsageError((prev) => ({
|
||||
...prev,
|
||||
[name]: err instanceof Error ? err.message : "unknown error",
|
||||
}));
|
||||
} finally {
|
||||
setUsageBusy((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Every profile's quota, once per profile per mount: a retry is the row's
|
||||
// own button, so a failed account is not re-asked on every render.
|
||||
useEffect(() => {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
const copies = (p: CcProfile) =>
|
||||
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
||||
/** Deletable unless it is the ONLY saved copy of the live account. */
|
||||
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const switchTo = (p: CcProfile) =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.ccConfig.activateProfile(p.name),
|
||||
t("profiles.switchSuccess", { name: p.label })
|
||||
);
|
||||
const remove = async (p: CcProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
message: t("profiles.deleteConfirmBody"),
|
||||
confirmLabel: t("profiles.deleteButton"),
|
||||
});
|
||||
if (!ok) return;
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.ccConfig.deleteProfile(p.name),
|
||||
t("profiles.deleteSuccess", { name: p.label })
|
||||
);
|
||||
};
|
||||
const pick = (fn: (p: CcProfile) => void) => () => {
|
||||
const p = ctx.menu!.item;
|
||||
ctx.close();
|
||||
fn(p);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
|
||||
{mismatchEmail && (
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${rowCls} space-y-1`}>
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("profiles.activeLabel")}
|
||||
</div>
|
||||
{active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{active.email || active.displayName || active.accountUuid}
|
||||
</div>
|
||||
<IdentityLine identity={active} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||
)}
|
||||
{active && !activeSaved && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items?.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
className={rowCls}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.active && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!p.active && (
|
||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
|
||||
{!ctx.menu.item.active && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||
{t("profiles.switchButton")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
|
||||
onSelect={pick((p) => void checkUsage(p.name))}
|
||||
>
|
||||
{t("profiles.usageCheck")}
|
||||
</MenuItem>
|
||||
{deletable(ctx.menu.item) && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy !== null}
|
||||
testId="profile-menu-delete"
|
||||
onSelect={pick((p) => void remove(p))}
|
||||
>
|
||||
{t("profiles.deleteButton")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
<LoginHint text={t("profiles.loginHint")} command="claude auth login" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||
* reports — the 5-hour session allowance and the 7-day one. Reading a saved
|
||||
* profile's usage may refresh its OAuth token (rotating it on disk), so it
|
||||
* happens once per mount or click, never on a poll.
|
||||
*/
|
||||
function ProfileUsageLine({
|
||||
usage,
|
||||
error,
|
||||
busy,
|
||||
disabled,
|
||||
onCheck,
|
||||
}: {
|
||||
usage?: CcProfileUsage;
|
||||
error?: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.resetsAt
|
||||
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||
: undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="text-status-danger">{error}</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{t("profiles.usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!usage) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
title={t("profiles.usageHint")}
|
||||
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||
<span title={resetTitle(usage.fiveHour)}>
|
||||
{t("profiles.usageFiveHour")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||
</span>
|
||||
<span title={resetTitle(usage.sevenDay)}>
|
||||
{t("profiles.usageSevenDay")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
* @file AccountSettings.test.tsx
|
||||
* @description Settings → Account: shows the account's own information, saves
|
||||
* only a changed display name/email, refuses a password change whose two new
|
||||
* passwords differ, sends the current + new password, asks before signing
|
||||
* the other devices out, and uploads/removes the avatar.
|
||||
* passwords differ, keeps a password that misses a rule from being sent,
|
||||
* sends the current + new password, asks before signing
|
||||
* the other devices out, and uploads/removes the avatar or sets it from a link.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -17,6 +18,7 @@ const me = vi.hoisted(() => ({
|
||||
changePassword: vi.fn(),
|
||||
signOutOthers: vi.fn(),
|
||||
setAvatar: vi.fn(),
|
||||
setAvatarFromUrl: vi.fn(),
|
||||
removeAvatar: vi.fn(),
|
||||
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
|
||||
}));
|
||||
@@ -76,6 +78,55 @@ describe("AccountSettings", () => {
|
||||
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
||||
});
|
||||
|
||||
/** jsdom never loads an image: play the browser's answer to `src`. */
|
||||
const stubImage = (loads: boolean) =>
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class {
|
||||
naturalWidth = loads ? 64 : 0;
|
||||
referrerPolicy = "";
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_v: string) {
|
||||
setTimeout(() => (loads ? this.onload?.() : this.onerror?.()), 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("refuses a link this browser cannot load as an image, and saves nothing", async () => {
|
||||
const ue = userEvent.setup();
|
||||
stubImage(false);
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
|
||||
await ue.type(
|
||||
screen.getByTestId("account-avatar-url-input"),
|
||||
"https://chat.example/api/v4/users/x/image{Enter}"
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("account-avatar-url-input")).toBeTruthy());
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(me.setAvatarFromUrl).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sets the avatar from a link and closes the field", async () => {
|
||||
const ue = userEvent.setup();
|
||||
stubImage(true);
|
||||
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
|
||||
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
||||
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
|
||||
await ue.type(screen.getByTestId("account-avatar-url-input"), " https://x.vn/me.png {Enter}");
|
||||
await waitFor(() => expect(me.setAvatarFromUrl).toHaveBeenCalledWith("https://x.vn/me.png"));
|
||||
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
|
||||
"/api/me/avatar?v=2026-10-21T00:00:00Z"
|
||||
);
|
||||
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the account's own information", async () => {
|
||||
render(<AccountSettings />);
|
||||
const card = await screen.findByTestId("account-card");
|
||||
@@ -114,9 +165,21 @@ describe("AccountSettings", () => {
|
||||
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
|
||||
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
|
||||
// No upper-case letter: the checklist says so and the button stays off.
|
||||
expect(
|
||||
screen
|
||||
.getByTestId("password-rules")
|
||||
.querySelector('[data-rule="upper"]')
|
||||
?.getAttribute("data-ok")
|
||||
).toBe("false");
|
||||
expect(screen.getByRole("button", { name: "Change password" })).toBeDisabled();
|
||||
await ue.clear(screen.getByLabelText("New password"));
|
||||
await ue.clear(screen.getByLabelText("Confirm new password"));
|
||||
await ue.type(screen.getByLabelText("New password"), "New-pass-123");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "New-pass-123");
|
||||
await ue.click(screen.getByRole("button", { name: "Change password" }));
|
||||
await waitFor(() =>
|
||||
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123")
|
||||
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "New-pass-123")
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file Tests for OmpSharingSettings — the admin's Oh My Pi login-sharing
|
||||
* switch in Settings → AI tools: hidden in local mode, for a non-admin and
|
||||
* outside S3, and a toggle saves through PUT /api/ai-tools/omp-sharing and
|
||||
* shows the daemons' state.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { OmpSharingSettings } from "../OmpSharingSettings";
|
||||
import { api, type OmpSharingState } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiTools: { ompSharing: vi.fn(), setOmpSharing: vi.fn() } },
|
||||
}));
|
||||
|
||||
const get = vi.mocked(api.aiTools.ompSharing);
|
||||
const set = vi.mocked(api.aiTools.setOmpSharing);
|
||||
const off: OmpSharingState = {
|
||||
available: true,
|
||||
enabled: false,
|
||||
installed: true,
|
||||
broker: { running: false, error: null },
|
||||
gateway: { running: false, error: null },
|
||||
};
|
||||
|
||||
describe("OmpSharingSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.isAdmin = true;
|
||||
perms.local = false;
|
||||
});
|
||||
|
||||
it("lets an admin turn sharing on and shows it running", async () => {
|
||||
get.mockResolvedValue(off);
|
||||
set.mockResolvedValue({
|
||||
...off,
|
||||
enabled: true,
|
||||
broker: { running: true },
|
||||
gateway: { running: true, port: 4827 },
|
||||
});
|
||||
render(<OmpSharingSettings />);
|
||||
const sw = await screen.findByRole("switch", { name: "Share this server's Oh My Pi login" });
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||
expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("Running");
|
||||
});
|
||||
|
||||
it("names a daemon's failure", async () => {
|
||||
get.mockResolvedValue({
|
||||
...off,
|
||||
enabled: true,
|
||||
gateway: { running: false, error: "port 4827 is taken" },
|
||||
});
|
||||
render(<OmpSharingSettings />);
|
||||
expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("port 4827 is taken");
|
||||
});
|
||||
|
||||
it("renders nothing outside S3, for a non-admin or in local mode", async () => {
|
||||
get.mockResolvedValue({ ...off, available: false });
|
||||
const r = render(<OmpSharingSettings />);
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(r.container).toBeEmptyDOMElement();
|
||||
r.unmount();
|
||||
perms.isAdmin = false;
|
||||
const r2 = render(<OmpSharingSettings />);
|
||||
expect(r2.container).toBeEmptyDOMElement();
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @file SessionDefaultsSettings.test.tsx
|
||||
* @description Session defaults: Bypass becomes Claude's default permission
|
||||
* mode or Codex's default bypass only after the confirm, a cancelled confirm
|
||||
* keeps the old default, the plain modes save at once, and the pet chat's
|
||||
* safe read never returns bypass.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
|
||||
|
||||
const aiTools = vi.hoisted(() => ({
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
||||
{ id: "codex", label: "Codex", installed: true, enabled: true },
|
||||
],
|
||||
}));
|
||||
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
||||
const confirm = vi.mocked(confirmDialog);
|
||||
|
||||
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
|
||||
|
||||
describe("SessionDefaultsSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
confirm.mockReset();
|
||||
});
|
||||
|
||||
it("saves a plain mode without asking", () => {
|
||||
render(<SessionDefaultsSettings />);
|
||||
fireEvent.click(radio(/plan/i));
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(readDefaultMode()).toBe("plan");
|
||||
});
|
||||
|
||||
it("makes Bypass Claude's default only once confirmed", async () => {
|
||||
confirm.mockResolvedValueOnce(false);
|
||||
render(<SessionDefaultsSettings />);
|
||||
fireEvent.click(radio(/bypass/i));
|
||||
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
|
||||
expect(readDefaultMode()).toBe("auto");
|
||||
|
||||
confirm.mockResolvedValueOnce(true);
|
||||
fireEvent.click(radio(/bypass/i));
|
||||
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
|
||||
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||
});
|
||||
|
||||
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
|
||||
confirm.mockResolvedValueOnce(true);
|
||||
render(<SessionDefaultsSettings />);
|
||||
const sw = screen.getByRole("switch");
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows nothing when neither Claude nor Codex is enabled", () => {
|
||||
const saved = aiTools.tools;
|
||||
aiTools.tools = [];
|
||||
const { container } = render(<SessionDefaultsSettings />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
aiTools.tools = saved;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @file Tests for ToolProfilesSettings — Settings → AI tools → Profiles: one
|
||||
* tab per AI tool. Claude's tab asks for EVERY saved account's quota once (a
|
||||
* failing account costs the others nothing), and its right-click delete asks
|
||||
* first and never offers to delete the only copy of the live account; Codex's
|
||||
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
|
||||
* switched; and in server mode a profile links into the account pool.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../../i18n";
|
||||
import { settleConfirm } from "../../../lib/confirm";
|
||||
import { ToolProfilesSettings } from "../ToolProfilesSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: true, seesAll: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../hooks/useAiTools", () => ({
|
||||
useAiTools: () => ({
|
||||
byId: (id: string) => ({ id, installed: true, enabled: true }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const m = vi.hoisted(() => {
|
||||
const window = (utilization: number) => ({ utilization, resetsAt: "2026-09-18T08:00:00Z" });
|
||||
return {
|
||||
window,
|
||||
cc: {
|
||||
profiles: vi.fn(),
|
||||
profileUsage: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
activateProfile: vi.fn(),
|
||||
saveProfile: vi.fn(),
|
||||
renameProfile: vi.fn(),
|
||||
},
|
||||
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
||||
pool: { list: vi.fn() },
|
||||
};
|
||||
});
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
ccConfig: m.cc,
|
||||
toolProfiles: m.tp,
|
||||
accountPool: m.pool,
|
||||
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
||||
},
|
||||
}));
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function show() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ToolProfilesSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.local = true;
|
||||
m.cc.profiles.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||
],
|
||||
active: { accountUuid: "u1", email: "a@b.c" },
|
||||
});
|
||||
m.cc.profileUsage.mockImplementation(async (name: string) => {
|
||||
if (name === "spare") throw new Error("this profile's saved login has been superseded");
|
||||
return { name, refreshed: false, fiveHour: m.window(8), sevenDay: m.window(70) };
|
||||
});
|
||||
m.cc.deleteProfile.mockResolvedValue({ ok: true, name: "spare" });
|
||||
m.tp.list.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "codex",
|
||||
supported: true,
|
||||
items: [
|
||||
{
|
||||
name: "a",
|
||||
label: "Alice",
|
||||
active: true,
|
||||
kind: "chatgpt",
|
||||
email: "a@x",
|
||||
detail: "plus",
|
||||
},
|
||||
{ name: "b", label: "Bob", active: false, kind: "apikey", email: null, detail: "…1234" },
|
||||
],
|
||||
active: { kind: "chatgpt", email: "a@x", detail: "plus" },
|
||||
},
|
||||
{ tool: "omp", supported: true, items: [], active: null },
|
||||
{ tool: "antigravity", supported: false, reason: "keyring", items: [], active: null },
|
||||
],
|
||||
});
|
||||
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
||||
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — Claude", () => {
|
||||
it("asks for every saved account's quota once, a failure costing the others nothing", async () => {
|
||||
await show();
|
||||
await settle();
|
||||
expect(m.cc.profileUsage).toHaveBeenCalledWith("work");
|
||||
expect(m.cc.profileUsage).toHaveBeenCalledWith("spare");
|
||||
expect(m.cc.profileUsage.mock.calls.filter((c) => c[0] === "work")).toHaveLength(1);
|
||||
expect(await screen.findByText("8%")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes from the right-click menu only after confirming", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||
await settle();
|
||||
expect(m.cc.deleteProfile).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(m.cc.deleteProfile).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("offers no delete for the only saved copy of the live account", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — other tools", () => {
|
||||
it("switches a Codex login through /api/tool-profiles", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.click(screen.getByRole("tab", { name: /codex/i }));
|
||||
const bob = screen.getByTestId("profile-row-codex-b");
|
||||
expect(bob).toHaveTextContent("…1234");
|
||||
await user.click(screen.getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.tp.activate).toHaveBeenCalledWith("codex", "b");
|
||||
expect(m.tp.list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("says why Antigravity cannot be switched", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.click(screen.getByRole("tab", { name: /antigravity/i }));
|
||||
expect(screen.getByText(/system keyring/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — account pool", () => {
|
||||
it("is not shown in local mode", async () => {
|
||||
await show();
|
||||
expect(m.pool.list).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Move to pool/)).toBeNull();
|
||||
});
|
||||
|
||||
it("in server mode shows the pool's size and links a profile into it", async () => {
|
||||
perms.local = false;
|
||||
await show();
|
||||
expect(screen.getByText(/holds 1 account/)).toBeInTheDocument();
|
||||
const [link] = screen.getAllByRole("link", { name: /Move to pool/ });
|
||||
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @file PasswordRules.tsx
|
||||
* @description The checklist under a "new password" field the account picks
|
||||
* for itself (Settings → Account, the first-login password change, an admin's
|
||||
* own password on the Users page): each rule of `lib/passwordRules.ts`, ticked
|
||||
* as the password typed passes it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Circle } from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_RULES, passwordProblems } from "../../lib/passwordRules";
|
||||
|
||||
export function PasswordRules({ password, username }: { password: string; username?: string }) {
|
||||
const { t } = useTranslation("users");
|
||||
const failed = new Set(passwordProblems(password, username));
|
||||
return (
|
||||
<ul className="grid gap-x-4 gap-y-1 text-xs sm:grid-cols-2" data-testid="password-rules">
|
||||
{PASSWORD_RULES.map((rule) => {
|
||||
const ok = password.length > 0 && !failed.has(rule);
|
||||
return (
|
||||
<li
|
||||
key={rule}
|
||||
className={`flex items-center gap-1.5 ${ok ? "text-status-success" : "text-fg-muted"}`}
|
||||
data-rule={rule}
|
||||
data-ok={ok}
|
||||
>
|
||||
{ok ? (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
{t(`passwordRules.${rule}`, { min: PASSWORD_MIN_LENGTH })}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* @file PoolGroups.tsx
|
||||
* @description The account pool's GROUPS (`/api/accounts/groups`,
|
||||
* server/lib/pool-groups.js), shown as cards above the tool tabs: each team's
|
||||
* members run only on the accounts its group lists, of every tool, while a
|
||||
* user in no group runs on the shared pool (the accounts no group lists). A
|
||||
* card shows the group's members and its accounts per tool; an admin creates,
|
||||
* edits (name, members — one group per user, so ticking one moves them here —
|
||||
* and accounts) or deletes one, each time with their own password again.
|
||||
* `groupsOf` tells an account card which groups use it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type PoolGroup,
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
|
||||
/** Pool tools with their tab labels, in tab order. */
|
||||
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
];
|
||||
|
||||
/** Account names of `tool` in the pool. */
|
||||
function accountNames(pool: AccountPool, tool: PoolTool): string[] {
|
||||
const list = tool === "claude" ? pool.accounts : tool === "codex" ? pool.codex : pool.cursor;
|
||||
return (list ?? []).map((a) => a.name);
|
||||
}
|
||||
|
||||
/** The groups that list account `name` of `tool`. */
|
||||
export function groupsOf(pool: AccountPool, tool: PoolTool, name: string): PoolGroup[] {
|
||||
return (pool.groups ?? []).filter((g) => (g.accounts[tool] ?? []).includes(name));
|
||||
}
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`pool.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
export function PoolGroups({
|
||||
pool,
|
||||
isAdmin,
|
||||
onChanged,
|
||||
}: {
|
||||
pool: AccountPool;
|
||||
isAdmin: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const groups = pool.groups ?? [];
|
||||
const [editing, setEditing] = useState<PoolGroup | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 border-b border-border" data-testid="pool-groups">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Users className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<div className="flex-1 min-w-[14rem]">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("pool.groups.title")}</h3>
|
||||
<p className="text-xs text-fg-muted">{t("pool.groups.description")}</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setEditing("new")} className="btn-ghost text-xs">
|
||||
<Plus className="h-4 w-4" /> {t("pool.groups.new")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<p className="mt-3 text-xs text-fg-muted">{t("pool.groups.empty")}</p>
|
||||
) : (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
className="rounded-lg border border-border bg-surface-2 p-3"
|
||||
data-testid="pool-group"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{g.name}</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.groups.members", { count: g.users.length })}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(g)}
|
||||
title={t("pool.groups.edit", { name: g.name })}
|
||||
aria-label={t("pool.groups.edit", { name: g.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleting(g)}
|
||||
title={t("pool.groups.delete", { name: g.name })}
|
||||
aria-label={t("pool.groups.delete", { name: g.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{g.users.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{g.users.map((u) => (
|
||||
<span key={u.userId} className="badge py-0 text-[11px]">
|
||||
{u.username}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 space-y-1">
|
||||
{POOL_TOOL_LABELS.map(({ id, label }) => {
|
||||
const names = g.accounts[id] ?? [];
|
||||
return (
|
||||
<div key={id} className="flex items-center gap-1.5 text-[11px]">
|
||||
<AiToolIcon tool={{ id, label }} className="h-3.5 w-3.5" />
|
||||
<span className="text-fg-secondary">{label}:</span>
|
||||
<span className={names.length ? "text-fg-primary" : "text-fg-muted"}>
|
||||
{names.length ? names.join(", ") : t("pool.groups.noAccounts")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<GroupDialog
|
||||
pool={pool}
|
||||
group={editing === "new" ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.groups.saved", { name }));
|
||||
setEditing(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<DeleteGroupDialog
|
||||
group={deleting}
|
||||
onClose={() => setDeleting(null)}
|
||||
onDone={() => {
|
||||
toast.success(t("pool.groups.deleted", { name: deleting.name }));
|
||||
setDeleting(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a group, or edit one: its name, its members and its accounts. */
|
||||
function GroupDialog({
|
||||
pool,
|
||||
group,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
pool: AccountPool;
|
||||
group: PoolGroup | null;
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [name, setName] = useState(group?.name ?? "");
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [members, setMembers] = useState<Set<number>>(
|
||||
() => new Set(group?.users.map((u) => u.userId) ?? [])
|
||||
);
|
||||
const [accounts, setAccounts] = useState<Record<PoolTool, Set<string>>>(() => ({
|
||||
claude: new Set(group?.accounts.claude ?? []),
|
||||
codex: new Set(group?.accounts.codex ?? []),
|
||||
cursor: new Set(group?.accounts.cursor ?? []),
|
||||
}));
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
}, []);
|
||||
|
||||
const groupOfUser = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.id !== group?.id && g.users.some((u) => u.userId === userId));
|
||||
const toggle = <T,>(set: Set<T>, v: T, on: boolean) => {
|
||||
const next = new Set(set);
|
||||
if (on) next.add(v);
|
||||
else next.delete(v);
|
||||
return next;
|
||||
};
|
||||
const ready = name.trim().length > 0 && current.length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const id = group
|
||||
? group.id
|
||||
: (await api.accountPool.createGroup(name.trim(), current)).group.id;
|
||||
await api.accountPool.updateGroup(
|
||||
id,
|
||||
{
|
||||
...(group ? { name: name.trim() } : {}),
|
||||
userIds: [...members],
|
||||
accounts: Object.fromEntries(
|
||||
POOL_TOOL_LABELS.map(({ id: tool }) => [tool, [...accounts[tool]]])
|
||||
),
|
||||
},
|
||||
current
|
||||
);
|
||||
onDone(name.trim());
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={group ? t("pool.groups.edit", { name: group.name }) : t("pool.groups.new")}
|
||||
message={t("pool.groups.hint")}
|
||||
confirmLabel={t("pool.save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.groups.name")}</span>
|
||||
<input
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("pool.groups.name")}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("pool.groups.memberList")}
|
||||
</span>
|
||||
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users?.map((u) => {
|
||||
const other = groupOfUser(u.id);
|
||||
return (
|
||||
<div key={u.id} className="px-3 py-1.5">
|
||||
<Checkbox
|
||||
checked={members.has(u.id)}
|
||||
onChange={(on) => setMembers((s) => toggle(s, u.id, on))}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{other && (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.groups.inGroup", { group: other.name })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("pool.groups.accountList")}
|
||||
</span>
|
||||
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{POOL_TOOL_LABELS.map(({ id: tool, label }) => {
|
||||
const names = accountNames(pool, tool);
|
||||
return (
|
||||
<div key={tool} className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-fg-secondary">
|
||||
<AiToolIcon tool={{ id: tool, label }} className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
{names.length === 0 ? (
|
||||
<p className="mt-1 text-[11px] text-fg-muted">{t("pool.groups.noneInPool")}</p>
|
||||
) : (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{names.map((n) => (
|
||||
<Checkbox
|
||||
key={n}
|
||||
checked={accounts[tool].has(n)}
|
||||
onChange={(on) =>
|
||||
setAccounts((a) => ({ ...a, [tool]: toggle(a[tool], n, on) }))
|
||||
}
|
||||
label={n}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteGroupDialog({
|
||||
group,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
group: PoolGroup;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.accountPool.deleteGroup(group.id, current);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.groups.delete", { name: group.name })}
|
||||
message={t("pool.groups.deleteHint")}
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable), the workspaces the
|
||||
* account), a new password (typed or generated, copyable — someone else's
|
||||
* may be anything and makes them choose their own at next sign-in; your own
|
||||
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
@@ -20,6 +22,8 @@ import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { passwordProblems } from "../../lib/passwordRules";
|
||||
import { PasswordRules } from "./PasswordRules";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
@@ -34,11 +38,15 @@ export const ROLE_BADGE: Record<Role, string> = {
|
||||
};
|
||||
|
||||
/** A random password of `n` characters, no look-alikes (0/O, 1/l/I). */
|
||||
export function generatePassword(n = 16): string {
|
||||
export function generatePassword(n = 16, username?: string): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%";
|
||||
const bytes = new Uint32Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
// Drawn again until it passes every rule, so it also fits an own password.
|
||||
for (;;) {
|
||||
crypto.getRandomValues(bytes);
|
||||
const out = Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
if (passwordProblems(out, username).length === 0) return out;
|
||||
}
|
||||
}
|
||||
|
||||
type OwnedWorkspace = { id: number; title: string; cwd: string };
|
||||
@@ -110,8 +118,11 @@ export function UserEditDialog({
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// No minimum length (server/lib/auth.js) — a blank field keeps the password.
|
||||
const ready = changed && current.length > 0 && !busy;
|
||||
// A blank field keeps the password. Someone else's may be anything (they
|
||||
// choose their own at next sign-in); your own must pass every rule.
|
||||
const weakOwn =
|
||||
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -309,7 +320,10 @@ export function UserEditDialog({
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
<Field label={t("newPassword")} hint={t("edit.passwordHint")}>
|
||||
<Field
|
||||
label={t("newPassword")}
|
||||
hint={self ? t("edit.ownPasswordHint") : t("edit.passwordHint")}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
@@ -343,7 +357,7 @@ export function UserEditDialog({
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
onClick={() => {
|
||||
setPassword(generatePassword());
|
||||
setPassword(generatePassword(16, user.username));
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
@@ -351,6 +365,11 @@ export function UserEditDialog({
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{self && password.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<PasswordRules password={password} username={user.username} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("workspaces")}>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
/**
|
||||
* @file AccountPoolCard.test.tsx
|
||||
* @description The Users page's account pool card: lists the Claude and Codex
|
||||
* accounts with their live panes and quota, says what an empty pool means, an
|
||||
* @description The Users page's account pool card: one tab per AI tool — the
|
||||
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
|
||||
* Oh My Pi's login sharing, and a "no pool" note for any other installed
|
||||
* tool; says what an empty pool means per tool, an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control.
|
||||
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||
* it, saved with the admin's own password; the users show on the account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { AccountPool } from "../../../lib/api";
|
||||
|
||||
const accountPool = vi.hoisted(() => ({
|
||||
@@ -19,14 +23,32 @@ const accountPool = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
setUsers: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
updateGroup: vi.fn(),
|
||||
deleteGroup: vi.fn(),
|
||||
}));
|
||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const aiTools = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
ompSharing: vi.fn(),
|
||||
setOmpSharing: vi.fn(),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { accountPool } };
|
||||
return { ...actual, api: { accountPool, users, aiTools } };
|
||||
});
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
|
||||
/** The card reads `?poolAdd=` and links to Settings: it needs a router. */
|
||||
const card = (isAdmin: boolean, url = "/admin") => (
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const POOL: AccountPool = {
|
||||
accounts: [
|
||||
{
|
||||
@@ -42,9 +64,44 @@ const POOL: AccountPool = {
|
||||
spend7d: 12,
|
||||
tokens5h: 250_000,
|
||||
tokens7d: 3_400_000,
|
||||
quota: {
|
||||
fiveHour: {
|
||||
utilization: 42,
|
||||
resetsAt: new Date(Date.now() + 2 * 3600e3 + 630e3).toISOString(),
|
||||
},
|
||||
sevenDay: {
|
||||
utilization: 15,
|
||||
resetsAt: new Date(Date.now() + 3 * 86400e3 + 3630e3).toISOString(),
|
||||
},
|
||||
checkedAt: "2026-10-01T00:00:00Z",
|
||||
error: null,
|
||||
},
|
||||
quotaPercent: 42,
|
||||
full: false,
|
||||
},
|
||||
{
|
||||
name: "team-2",
|
||||
kind: "token",
|
||||
email: null,
|
||||
refreshError: null,
|
||||
addedAt: "2026-10-01T00:00:00Z",
|
||||
expiresAt: null,
|
||||
expired: false,
|
||||
livePanes: 0,
|
||||
spend5h: 0,
|
||||
spend7d: 0,
|
||||
tokens5h: 0,
|
||||
tokens7d: 0,
|
||||
// A 5-hour window that has reset since the reading is full again.
|
||||
quota: {
|
||||
fiveHour: { utilization: 100, resetsAt: "2020-01-01T00:00:00Z" },
|
||||
sevenDay: null,
|
||||
checkedAt: "2026-10-01T00:00:00Z",
|
||||
error: null,
|
||||
},
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
},
|
||||
],
|
||||
codex: [
|
||||
{
|
||||
@@ -63,9 +120,37 @@ const POOL: AccountPool = {
|
||||
blockPercent: 90,
|
||||
};
|
||||
|
||||
const tool = (id: string, label: string, installed = true) => ({ id, label, installed });
|
||||
const OMP = {
|
||||
available: true,
|
||||
enabled: false,
|
||||
installed: true,
|
||||
broker: { running: false },
|
||||
gateway: { running: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
aiTools.list.mockResolvedValue({
|
||||
tools: [
|
||||
tool("claude", "Claude Code"),
|
||||
tool("codex", "Codex"),
|
||||
tool("cursor", "Cursor CLI"),
|
||||
tool("omp", "Oh My Pi"),
|
||||
tool("antigravity", "Antigravity"),
|
||||
tool("goose", "Goose", false),
|
||||
],
|
||||
default: "claude",
|
||||
});
|
||||
aiTools.ompSharing.mockResolvedValue(OMP);
|
||||
aiTools.setOmpSharing.mockResolvedValue({ ...OMP, enabled: true });
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
users.list.mockResolvedValue({
|
||||
users: [
|
||||
{ id: 7, username: "mia", disabled: false },
|
||||
{ id: 8, username: "leo", disabled: false },
|
||||
],
|
||||
});
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
@@ -81,28 +166,153 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("lists every account, and explains an empty pool", async () => {
|
||||
const { unmount } = render(<AccountPoolCard isAdmin={false} />);
|
||||
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(card(false));
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
|
||||
expect(screen.queryByText("cx-1")).toBeNull();
|
||||
const [account, second] = screen.getAllByTestId("pool-account");
|
||||
// Every account of the pool has its card; quota is shown as what is LEFT.
|
||||
expect(second).toHaveTextContent("team-2");
|
||||
const quota = screen.getAllByTestId("pool-quota")[0]!;
|
||||
expect(quota).toHaveTextContent("5 hours");
|
||||
expect(quota).toHaveTextContent("58%");
|
||||
expect(quota).toHaveTextContent("resets in 2h 10m");
|
||||
expect(quota).toHaveTextContent("Week");
|
||||
expect(quota).toHaveTextContent("85%");
|
||||
expect(quota).toHaveTextContent("resets in 3d 1h");
|
||||
const reset = screen.getAllByTestId("pool-quota")[1]!;
|
||||
expect(reset).toHaveTextContent("100%");
|
||||
expect(reset).toHaveTextContent("—");
|
||||
expect(account).toHaveTextContent("250.0K tokens");
|
||||
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
||||
expect(account).toHaveTextContent("2 live");
|
||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
|
||||
// Without groups nothing is said about them on an account.
|
||||
expect(screen.queryByText("Shared pool")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
const codexTab = screen.getByRole("tab", { name: /Codex/ });
|
||||
expect(codexTab).toHaveTextContent("1");
|
||||
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
|
||||
await user.click(codexTab);
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("team-1")).toBeNull();
|
||||
// A pool with no account still has its tab, with what that means.
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
|
||||
// An installed tool without a pool says so; an uninstalled one gets no tab.
|
||||
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
||||
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
||||
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
||||
render(card(true));
|
||||
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a Cursor API key from the Cursor tab", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
||||
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(screen.queryByLabelText("From")).toBeNull();
|
||||
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "cursor", name: "cu-1", token: "key_abc" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||
});
|
||||
|
||||
it("shares a saved profile from its card", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the groups as cards and saves a new one with members and accounts", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
users: [{ userId: 8, username: "leo" }],
|
||||
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const groupCard = await screen.findByTestId("pool-group");
|
||||
expect(groupCard).toHaveTextContent("Team A");
|
||||
expect(groupCard).toHaveTextContent("leo");
|
||||
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
|
||||
await user.click(screen.getByRole("button", { name: "New group" }));
|
||||
await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Group name"), "Team B");
|
||||
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
|
||||
expect(screen.getByText("now in Team A")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
2,
|
||||
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||
});
|
||||
|
||||
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Oh My Pi/ }));
|
||||
expect(screen.getByTestId("omp-pool-status")).toHaveTextContent(/Sharing is off/);
|
||||
await user.click(screen.getByRole("switch"));
|
||||
await waitFor(() => expect(aiTools.setOmpSharing).toHaveBeenCalledWith(true));
|
||||
expect(screen.getByRole("tab", { name: /Oh My Pi/ })).toHaveTextContent("shared");
|
||||
});
|
||||
|
||||
it("moves a saved login in, the server's own included", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const select = await screen.findByLabelText("Profile");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||
@@ -125,9 +335,9 @@ describe("AccountPoolCard", () => {
|
||||
it("adds a setup-token only with the admin's own password", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
@@ -150,7 +360,9 @@ describe("AccountPoolCard", () => {
|
||||
it("removes an account with the admin's own password", async () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Codex/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
@@ -158,4 +370,79 @@ describe("AccountPoolCard", () => {
|
||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||
);
|
||||
});
|
||||
|
||||
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
pins: {
|
||||
claude: [
|
||||
{ userId: 8, username: "leo", account: "team-1" },
|
||||
{ userId: 7, username: "mia", account: "team-0" },
|
||||
],
|
||||
codex: [],
|
||||
},
|
||||
});
|
||||
accountPool.setUsers.mockResolvedValue({
|
||||
added: [{ userId: 7, username: "mia", switched: "switched" }],
|
||||
removed: [{ userId: 8, username: "leo" }],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const leo = await screen.findByRole("checkbox", { name: /leo/ });
|
||||
const mia = screen.getByRole("checkbox", { name: /mia/ });
|
||||
expect(leo).toHaveAttribute("aria-checked", "true");
|
||||
expect(mia).toHaveAttribute("aria-checked", "false");
|
||||
expect(screen.getByText("now on team-0")).toBeInTheDocument();
|
||||
await user.click(mia);
|
||||
await user.click(leo);
|
||||
const save = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save"
|
||||
)!;
|
||||
expect(save).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(save);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||
);
|
||||
});
|
||||
|
||||
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "cx" } });
|
||||
accountPool.profiles.mockImplementation(async (tool: string) =>
|
||||
tool === "codex"
|
||||
? {
|
||||
profiles: [
|
||||
{ name: "cx", label: "Codex", email: "c@x", subscriptionType: "plus", active: false },
|
||||
],
|
||||
}
|
||||
: { profiles: [] }
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "codex", name: "cx", profile: "cx" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("links back to the profiles in Settings", async () => {
|
||||
render(card(false));
|
||||
const link = await screen.findByRole("link", { name: "Profiles in Settings" });
|
||||
expect(link.getAttribute("href")).toBe("/settings/agents#tool-profiles");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { api } from "../../lib/api";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { laneChangeMatcher } from "../../lib/laneChange";
|
||||
import type { PipelineStats } from "../../lib/types";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||
function pct(n: number, total: number): number {
|
||||
@@ -160,7 +161,7 @@ export function LanePipelinePanel({
|
||||
type="button"
|
||||
data-testid={`pipeline-stat-command-${n.id}`}
|
||||
title={t("lanes.copyCommand", { command: n.command })}
|
||||
onClick={() => void navigator.clipboard?.writeText(n.command || "")}
|
||||
onClick={() => void copyText(n.command || "")}
|
||||
className="block max-w-full truncate font-mono text-[10px] text-fg-muted hover:text-accent"
|
||||
>
|
||||
{n.command}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
|
||||
"deleteConfirm": "Delete",
|
||||
"installTitle": "Install “{{title}}”?",
|
||||
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/. A skill can carry scripts your agent will run — only install what you trust:",
|
||||
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/, so every AI tool and session finds it. A skill can carry scripts your agent will run — only install what you trust:",
|
||||
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
|
||||
"installConfirm": "Install",
|
||||
"replaceTitle": "Replace your skill “{{name}}”?",
|
||||
@@ -70,7 +70,8 @@
|
||||
"form.save": "Add",
|
||||
"form.cancel": "Cancel",
|
||||
"added": "Added",
|
||||
"files": "{{count}} files",
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} files",
|
||||
"close": "Close",
|
||||
"fromSession": "From a session",
|
||||
"form.saveEdit": "Save",
|
||||
@@ -78,7 +79,7 @@
|
||||
"skill.tab.write": "Write new",
|
||||
"skill.tab.existing": "From my skills",
|
||||
"skill.name": "Skill name",
|
||||
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
|
||||
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ (and ~/.agents/skills/) and runs as /{{slug}}",
|
||||
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
|
||||
"skill.description": "Description — when Claude should use this skill",
|
||||
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
|
||||
@@ -110,5 +111,77 @@
|
||||
"file.fromComputer": "From your computer (≤ 25 MB)",
|
||||
"file.upload": "Upload",
|
||||
"file.tooBig": "The file is larger than 25 MB.",
|
||||
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
|
||||
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote",
|
||||
"editTitle": "Edit “{{title}}”",
|
||||
"edit.title": "Title",
|
||||
"edit.description": "Description",
|
||||
"previewFailed": "Could not load this file.",
|
||||
"truncated": "Showing the first 1 MB — download the file to see all of it.",
|
||||
"section.hub.own": "Shared by me",
|
||||
"section.hub.ownHint": "Yours, visible to every user",
|
||||
"section.hub.others": "Shared by others",
|
||||
"section.hub.othersHint": "What other users shared — install what you need",
|
||||
"section.mine.own": "Mine",
|
||||
"section.mine.ownHint": "What you added",
|
||||
"section.mine.others": "Installed from others",
|
||||
"section.mine.othersHint": "Shared by other users, installed in your account",
|
||||
"section.all.others": "Shared by others",
|
||||
"section.all.othersHint": "What other users shared",
|
||||
"section.all.own": "Mine",
|
||||
"section.all.ownHint": "What you added, shared or not",
|
||||
"pages": "Pages",
|
||||
"section.empty": "Nothing here yet.",
|
||||
"tab.all": "All",
|
||||
"installedBadge": "Installed",
|
||||
"installsTitle": "Installs",
|
||||
"installsYou": "You",
|
||||
"installsUnknown": "A removed user",
|
||||
"installsReplaced": "replaced an older copy",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallTitle": "Uninstall “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.",
|
||||
"uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.",
|
||||
"uninstalled": "Uninstalled {{name}}",
|
||||
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
|
||||
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
|
||||
"deleteKept": "Removed from your library — still in the Hub",
|
||||
"hubOnly": "Hub only",
|
||||
"hubOnlyHint": "You removed it from your library; it stays shared in the Hub",
|
||||
"hubRemove": "Remove from Hub",
|
||||
"hubRemoveTitle": "Remove “{{title}}” from the Hub?",
|
||||
"hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.",
|
||||
"edit.otherFiles": "Its other {{count}} file(s) are kept as they are.",
|
||||
"edit.reinstallNote": "Your installed copy keeps the old version until you uninstall it and install it again.",
|
||||
"preview.instructions": "Instructions",
|
||||
"preview.frontmatter": "Frontmatter (SKILL.md header)",
|
||||
"preview.whenUsed": "When Claude uses it",
|
||||
"preview.noDescription": "No description",
|
||||
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/",
|
||||
"blurb.skill": "Reusable instructions Claude follows",
|
||||
"blurb.plugin": "Bundles of skills, commands and hooks",
|
||||
"blurb.page": "Pages your sessions published on claude.ai",
|
||||
"blurb.file": "Documents and outputs worth keeping",
|
||||
"clearSearch": "Clear search",
|
||||
"categories": "Categories",
|
||||
"seeAll": "See all",
|
||||
"results_one": "{{count}} result",
|
||||
"results_other": "{{count}} results",
|
||||
"empty.title": "Your library is empty",
|
||||
"empty.hubTitle": "The Hub is quiet",
|
||||
"empty.searchTitle": "No matches",
|
||||
"empty.search": "Nothing matches this search or category — try other words, or pick All.",
|
||||
"details": "Details",
|
||||
"more": "More actions",
|
||||
"about": "About",
|
||||
"stat.kind": "Type",
|
||||
"stat.installs": "Installs",
|
||||
"stat.files": "Files",
|
||||
"stat.size": "Size",
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}"
|
||||
}
|
||||
|
||||
@@ -198,7 +198,15 @@
|
||||
"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}})"
|
||||
"signedInAs": "Signed in as {{name}} ({{role}})",
|
||||
"mustChangeTitle": "Choose your own password",
|
||||
"mustChangeSubtitle": "Your administrator gave you a starting password. Choose your own before you continue.",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirmPassword": "Confirm new password",
|
||||
"mismatch": "The two new passwords differ",
|
||||
"mustChangeSubmit": "Save and continue",
|
||||
"saving": "Saving…"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
|
||||
@@ -220,6 +220,15 @@
|
||||
"clone.remember": "Remember this login for my account",
|
||||
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
|
||||
"clone.sshSettings": "Open Settings → Git",
|
||||
"clone.partial": "Partial clone (faster for big repos)",
|
||||
"clone.partialHint": "Downloads every commit but a file's contents only when it is checked out (--filter=blob:none). Checking out an old revision later fetches from the remote.",
|
||||
"clone.queued": "Waiting in the clone queue — #{{position}} of {{waiting}} ({{running}}/{{limit}} clones running)",
|
||||
"clone.connecting": "Connecting…",
|
||||
"clone.phase.remote": "Server preparing",
|
||||
"clone.phase.receiving": "Downloading",
|
||||
"clone.phase.resolving": "Unpacking",
|
||||
"clone.phase.checkout": "Writing files",
|
||||
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
@@ -298,6 +307,7 @@
|
||||
"changes.undoCommitTitle": "Undo the last commit?",
|
||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
|
||||
"changes.undoCommitConfirm": "Undo commit",
|
||||
"changes.undoCommitPushed": "The last commit is already on the remote branch (as of the last fetch); undoing it would rewrite shared history. Revert it instead — or Fetch first if the remote no longer has it.",
|
||||
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
|
||||
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
|
||||
"changes.amendTitle": "Amend the last commit?",
|
||||
@@ -421,6 +431,9 @@
|
||||
"home.cloneRun": "Clone",
|
||||
"home.cloneRetry": "Retry the failed ones",
|
||||
"home.cloneDone": "Added",
|
||||
"home.cloneNeedSsh": "SSH links need an SSH key first — your account has none. Make one in Settings → Git and add its public key to {{hosts}} before cloning.",
|
||||
"home.cloneNeedLogin": "No saved login for {{hosts}}. A private repository needs your username and an access token (or save one in Settings → Git).",
|
||||
"home.cloneEnterLogin": "Enter a login",
|
||||
"home.suggested": "Untracked worktrees",
|
||||
"home.remove": "Remove",
|
||||
"home.missing": "missing",
|
||||
@@ -518,6 +531,9 @@
|
||||
"changes.unversionedGroup": "Unversioned Files",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit and Push",
|
||||
"changes.discardAll": "Discard all",
|
||||
"changes.discardAllTitle": "Discard all changes?",
|
||||
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
|
||||
"changes.amendAndPush": "Amend and Push",
|
||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||
"changes.tickFile": "Include {{path}} in the commit",
|
||||
@@ -548,6 +564,9 @@
|
||||
"fx.refresh": "Refresh",
|
||||
"fx.newFolder": "New folder",
|
||||
"fx.newFile": "New file",
|
||||
"fx.cloneRepo": "Clone repository",
|
||||
"fx.cloneSubmit": "Clone",
|
||||
"fx.cloned": "Cloned into {{path}} — opened as workspace “{{workspace}}”.",
|
||||
"fx.uploadFiles": "Upload files",
|
||||
"fx.uploadFolder": "Upload a folder",
|
||||
"fx.download": "Download",
|
||||
@@ -631,6 +650,8 @@
|
||||
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
||||
"fx.myHome": "Home",
|
||||
"fx.userHomes": "Users' homes",
|
||||
"fx.users": "Users",
|
||||
"fx.toggleWorkspaces": "Show or hide {{name}}’s workspaces",
|
||||
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
|
||||
"fx.readOnly": "Read-only",
|
||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||
"quotaActive": "in use",
|
||||
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Settings → AI tools → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
"quotaSignInExpired": "Sign-in expired",
|
||||
"quotaWindow": {
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
||||
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
||||
"bypass": "Bypass permissions for this run",
|
||||
"yolo": "YOLO",
|
||||
"yoloHint": "YOLO mode for this session: no sandbox, no approvals (--dangerously-bypass-approvals-and-sandbox)",
|
||||
"bypassShort": "Bypass",
|
||||
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||
"permissionMode": "Permission mode",
|
||||
"permissionPlan": "plan (read-only planning)",
|
||||
@@ -68,6 +71,8 @@
|
||||
"paste": "Drop to paste into the terminal",
|
||||
"uploading": "Uploading…",
|
||||
"failed": "Could not attach the file",
|
||||
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
||||
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
||||
"remove": "Remove attachment",
|
||||
"view": "View image"
|
||||
},
|
||||
@@ -84,6 +89,7 @@
|
||||
"findClose": "Close find"
|
||||
},
|
||||
"chat": {
|
||||
"codexPicker": "Opens Codex's own picker — answer it in the chat",
|
||||
"placeholder": "Write a message…",
|
||||
"send": "Send",
|
||||
"notSent": "The terminal is not connected — the message was not sent.",
|
||||
@@ -94,6 +100,9 @@
|
||||
"notStopped": "The terminal is not connected — nothing was stopped.",
|
||||
"add": "Add files, skills…",
|
||||
"upload": "Upload or add files",
|
||||
"pickLocal": "Choose a file on this computer",
|
||||
"pickLocalHint": "Attach a file on this machine by its path — nothing is uploaded",
|
||||
"pickLocalOpen": "The file picker is open on this computer's screen…",
|
||||
"projectFiles": "Project files",
|
||||
"contextFiles": "Context files",
|
||||
"recentUploads": "Recent uploads",
|
||||
@@ -131,7 +140,28 @@
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Type your answer…",
|
||||
"cancel": "Cancel (Esc)"
|
||||
"cancel": "Cancel (Esc)",
|
||||
"prev": "Previous question",
|
||||
"next": "Next question"
|
||||
},
|
||||
"request": {
|
||||
"notePlaceholder": "Tell the model what to do instead…",
|
||||
"send": "Send",
|
||||
"denyWithNote": "Deny with a note"
|
||||
},
|
||||
"artifact": {
|
||||
"preview": "Preview",
|
||||
"code": "Code",
|
||||
"copy": "Copy",
|
||||
"download": "Download",
|
||||
"share": "Save to Artifacts",
|
||||
"shared": "Saved to your Artifacts library — share it from the Artifacts page.",
|
||||
"cannotShare": "This file cannot be saved to Artifacts.",
|
||||
"expand": "Full screen",
|
||||
"collapse": "Exit full screen",
|
||||
"older": "Older version",
|
||||
"newer": "Newer version",
|
||||
"version": "Version {{n}} of {{total}}"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -210,7 +210,36 @@
|
||||
"moreSteps_other": "+{{count}} more",
|
||||
"quiet": "no new activity for {{time}}",
|
||||
"askDeclined": "Declined — no answer given",
|
||||
"turnDone": "Done in {{time}}"
|
||||
"turnDone": "Done in {{time}}",
|
||||
"turnReplied": "Replied in {{time}}",
|
||||
"agentsStill_one": "{{count}} agent still running",
|
||||
"agentsStill_other": "{{count}} agents still running",
|
||||
"agentsRunning_one": "{{count}} agent running in the background",
|
||||
"agentsRunning_other": "{{count}} agents running in the background",
|
||||
"agentContext": "{{tokens}} tokens",
|
||||
"agent": {
|
||||
"open": "View conversation",
|
||||
"prompt": "Task",
|
||||
"background": "background",
|
||||
"toolUses_one": "{{count}} tool call",
|
||||
"toolUses_other": "{{count}} tool calls",
|
||||
"status": {
|
||||
"running": "running",
|
||||
"done": "done",
|
||||
"error": "failed",
|
||||
"stopped": "stopped"
|
||||
},
|
||||
"hide": "Hide conversation"
|
||||
},
|
||||
"artifact": {
|
||||
"html": "Web page",
|
||||
"svg": "Image · SVG",
|
||||
"markdown": "Document",
|
||||
"mermaid": "Diagram",
|
||||
"react": "React component",
|
||||
"csv": "Table",
|
||||
"open": "Click to open"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -77,21 +77,13 @@
|
||||
"run_done_other": "{{count}} runs finished 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": [
|
||||
"good morning! ☀️",
|
||||
"morning! coffee first? ☕",
|
||||
"rise and shine 🌤️"
|
||||
],
|
||||
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
|
||||
"afternoon": [
|
||||
"good afternoon! 🐾",
|
||||
"hey, welcome back 😺",
|
||||
"afternoon! ready when you are ✨"
|
||||
],
|
||||
"evening": [
|
||||
"good evening! 🌆",
|
||||
"evening shift? I'm in 😼",
|
||||
"hey there, night owl-to-be 🌙"
|
||||
],
|
||||
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
|
||||
"night": [
|
||||
"burning the midnight oil? 🌙",
|
||||
"late one, huh? I'll keep watch 🦉",
|
||||
@@ -114,11 +106,7 @@
|
||||
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
||||
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
||||
"hello": "hi there! 😺 {{summary}}.",
|
||||
"thanks": [
|
||||
"anytime! 💜",
|
||||
"happy to help 😸",
|
||||
"you got it 🐾"
|
||||
],
|
||||
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
|
||||
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
||||
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||
"errorsNone": "no errors - all clean 🐾",
|
||||
@@ -175,41 +163,17 @@
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤"
|
||||
],
|
||||
"worried": [
|
||||
"that didn't look right 😟",
|
||||
"keeping an eye out 👀",
|
||||
"hmm, something's off 🫣"
|
||||
],
|
||||
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
"stuck": [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"great run! 😻",
|
||||
"love a tidy finish ✨",
|
||||
"purrfect 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"hmm, let me look… 🤔",
|
||||
"sniffing around… 🐾",
|
||||
"one sec, checking 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"on the prowl 👀",
|
||||
"watching your sessions 😼",
|
||||
"eyes peeled 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"wake me if something happens 😴",
|
||||
"curled up, all calm 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"all quiet 😺",
|
||||
"ready when you are 🐾",
|
||||
"just vibing ✨"
|
||||
]
|
||||
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Chat with {{name}}",
|
||||
@@ -1140,6 +1104,21 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Oh My Pi login sharing",
|
||||
"switch": "Share this server's Oh My Pi login",
|
||||
"hint": "Every user's omp sessions run on the logins the host account made with `omp login`, through omp's own auth broker and gateway on this machine. A session gets only a key that works while its pane runs, never the login itself. Only omp's built-in providers are shared.",
|
||||
"running": "Running: broker and gateway are up.",
|
||||
"starting": "Starting…",
|
||||
"notInstalled": "omp is not installed on this server.",
|
||||
"failed": "Not running: {{error}}"
|
||||
},
|
||||
"markdownView": {
|
||||
"label": "Open Markdown as",
|
||||
"description": "How a .md file opens in Files, Changes, the config viewer and a chat's file panel. Its Preview/Raw button still switches that one file. Saved to your account.",
|
||||
"preview": "Preview",
|
||||
"raw": "Raw"
|
||||
},
|
||||
"workspaceTabs": {
|
||||
"title": "Workspace tabs",
|
||||
"description": "Which sessions the session view's tab bar holds. Saved to your account.",
|
||||
@@ -1302,11 +1281,25 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Chat UI",
|
||||
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||
"description": "How a new Claude, Codex, omp, Cursor or Antigravity 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.",
|
||||
"labelDesc": "A new session first shows its conversation like Claude Desktop, with a message box, instead of its terminal (the terminal stays underneath). Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and questions are answered from cards in the chat.",
|
||||
"saveFailed": "Could not save: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
"title": "Session defaults",
|
||||
"description": "What a new session starts with in a workspace's prompt box. Saved for your account only; a pick in the prompt box changes only that one session.",
|
||||
"claudeMode": "Claude Code permission mode",
|
||||
"claudeModeDesc": "Bypass permissions lets every new Claude session run any tool without asking.",
|
||||
"codexSandbox": "Codex sandbox",
|
||||
"codexApproval": "Codex approval",
|
||||
"codexBypass": "Codex: YOLO mode",
|
||||
"codexBypassDesc": "Every new Codex session starts in YOLO mode — `--dangerously-bypass-approvals-and-sandbox`: no sandbox, no approvals.",
|
||||
"confirmTitle": "Make bypass the default for {{tool}}?",
|
||||
"confirmBody": "Every new {{tool}} session you start will run commands and edit files without asking, until you change this back. Use it only with folders and prompts you trust.",
|
||||
"confirmLabel": "Make it the default",
|
||||
"codexConfirmTitle": "Make YOLO the default for Codex?"
|
||||
},
|
||||
"agentInstructions": {
|
||||
"title": "My agent instructions",
|
||||
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
|
||||
@@ -1325,7 +1318,11 @@
|
||||
"remove": "Remove picture",
|
||||
"saved": "Picture updated",
|
||||
"removed": "Picture removed",
|
||||
"tooBig": "The picture must be at most 1 MB"
|
||||
"tooBig": "The picture must be at most 1 MB",
|
||||
"fromUrl": "Use a picture from a link",
|
||||
"urlPlaceholder": "https://… image link",
|
||||
"urlApply": "Use",
|
||||
"linkUnreachable": "That link did not load as an image in your browser — it may need a login (a Mattermost or other chat picture does). Download the picture and upload it instead."
|
||||
},
|
||||
"loadFailed": "Could not load your account: {{message}}",
|
||||
"memberSince": "Member since",
|
||||
@@ -1353,5 +1350,32 @@
|
||||
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
|
||||
"signedOut_one": "Signed out {{count}} other device",
|
||||
"signedOut_other": "Signed out {{count}} other devices"
|
||||
},
|
||||
"toolProfiles": {
|
||||
"title": "Profiles",
|
||||
"description": "Every AI tool's saved logins in one place. Switching swaps only the login; config, skills, sessions and history stay as they are.",
|
||||
"poolCount_one": "The account pool holds {{count}} account for this tool.",
|
||||
"poolCount_other": "The account pool holds {{count}} accounts for this tool.",
|
||||
"openPool": "Open the account pool",
|
||||
"moveToPool": "Move to pool",
|
||||
"moveToPoolHint": "Administration → Users → Account pool, with this profile chosen. The pool becomes the login's only holder and the profile is removed.",
|
||||
"saveButton": "Save this login as a profile",
|
||||
"noActive": "{{tool}} holds no login on this machine.",
|
||||
"noProfiles": "Could not load the profiles.",
|
||||
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||
"subtitle": {
|
||||
"codex": "A profile is one Codex login (a ChatGPT account or an API key): the whole auth.json. config.toml, sessions, skills and history stay where they are.",
|
||||
"omp": "A profile is the full set of logins Oh My Pi stores (every provider), read with `omp auth export` and put back with `omp auth import`. Settings, models and sessions stay where they are. Running omp sessions pick up the switch on their own."
|
||||
},
|
||||
"unsupported": {
|
||||
"antigravity": "Antigravity keeps its login in the system keyring, which AgentHub does not read or write, so it cannot be switched from here. Sign in or out inside agy itself."
|
||||
},
|
||||
"kind": {
|
||||
"chatgpt": "ChatGPT login",
|
||||
"apikey": "API key",
|
||||
"credentials": "Logins"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,5 +35,6 @@
|
||||
"autoFix": "Auto fix",
|
||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||
"autoFixWorkspace": "AgentHub update",
|
||||
"autoFixError": "Could not start the Auto fix session"
|
||||
"autoFixError": "Could not start the Auto fix session",
|
||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up."
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"username": "Username",
|
||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||
"password": "Password",
|
||||
"passwordHint": "No minimum length",
|
||||
"passwordHint": "Any starting password — they choose their own at first sign-in",
|
||||
"newPassword": "New password",
|
||||
"changeRole": "Change role",
|
||||
"disable": "Disable",
|
||||
@@ -68,6 +68,9 @@
|
||||
"errors": {
|
||||
"EBADIMAGE": "Only a PNG, JPEG, GIF or WebP picture",
|
||||
"ETOOBIG": "The picture must be at most 1 MB",
|
||||
"EBADURL": "Enter an https:// link to a picture.",
|
||||
"EPRIVATEURL": "That link points to a private or local address.",
|
||||
"EFETCH": "Could not download the picture from that link.",
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
@@ -81,7 +84,9 @@
|
||||
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
|
||||
"EBADROLE": "Unknown role.",
|
||||
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||
"ELASTADMIN": "The last admin cannot be deleted."
|
||||
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
|
||||
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
|
||||
"EPWCHANGE": "Choose your own password first."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
@@ -107,7 +112,7 @@
|
||||
"selfRole": "You cannot change your own role.",
|
||||
"selfDisable": "You cannot disable yourself.",
|
||||
"password": "Password",
|
||||
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere.",
|
||||
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere, and they choose their own at their next sign-in.",
|
||||
"show": "Show password",
|
||||
"hide": "Hide password",
|
||||
"copy": "Copy password",
|
||||
@@ -122,16 +127,15 @@
|
||||
"sessions": "Sign-ins",
|
||||
"signOut": "Sign out everywhere",
|
||||
"signOutHint": "Ends this user's {{n}} active sign-in(s); they sign in again with their password.",
|
||||
"noChanges": "No changes yet."
|
||||
"noChanges": "No changes yet.",
|
||||
"ownPasswordHint": "Leave blank to keep the current one. Your other sign-ins end."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
"description": "Claude and Codex accounts every user's new sessions run on — the least-used one is picked, and a login is written into the user's home by the server, so nobody signs in per user.",
|
||||
"description": "Accounts each tool's new sessions run on, one tab per AI tool. The least-used account is picked (or the one a user is pinned to), so nobody signs in per user.",
|
||||
"refresh": "Re-read quotas",
|
||||
"add": "Add account",
|
||||
"empty": "No accounts yet. Until you add one, each user's session needs that user's own sign-in.",
|
||||
"account": "Account",
|
||||
"tool": "Tool",
|
||||
"livePanes": "Live",
|
||||
"quota": "Quota",
|
||||
"spend": "5 hours",
|
||||
@@ -140,12 +144,6 @@
|
||||
"added": "added {{at}}",
|
||||
"expired": "expired",
|
||||
"full": "full",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Name",
|
||||
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
||||
"remove": "Remove {{name}}",
|
||||
@@ -166,37 +164,120 @@
|
||||
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
||||
"ENOTFOUND": "That saved login no longer exists.",
|
||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||
"EUPSTREAM": "Anthropic could not be reached. Try again."
|
||||
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
||||
"ENOUSER": "That user no longer exists.",
|
||||
"ENOGROUP": "That group no longer exists."
|
||||
},
|
||||
"source": "From",
|
||||
"sources": {
|
||||
"profile": "A saved login (Switch account profiles)",
|
||||
"profile": "A saved profile (Settings → AI tools → Profiles)",
|
||||
"credentials": "Paste .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable."
|
||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable.",
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account.",
|
||||
"cursor": "A Cursor API key from the Cursor dashboard (Integrations → API keys)."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Saved in Settings → Claude config → Switch account. The one this server's own Claude Code is logged into can be moved too: the server keeps running on it and refreshes it for everyone.",
|
||||
"profileHint": "Saved in Settings → AI tools → Profiles. The one this server's own CLI is logged into can be moved too: the server keeps running on it.",
|
||||
"chooseProfile": "Choose a saved login…",
|
||||
"noProfiles": "No saved logins — save one in Settings → Claude config → Switch account",
|
||||
"noProfiles": "No saved profiles — save one in Settings → AI tools → Profiles",
|
||||
"loadingProfiles": "Loading…",
|
||||
"activeHere": "in use on this server — keeps working",
|
||||
"nameFromProfile": "Leave blank to use the profile's name",
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key or auth.json"
|
||||
"codex": "API key or auth.json",
|
||||
"cursor": "Cursor API key"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account.",
|
||||
"cursor": "Create one in the Cursor dashboard under Integrations → API keys."
|
||||
},
|
||||
"refreshFailed": "refresh failed"
|
||||
"refreshFailed": "refresh failed",
|
||||
"pinned": "Used by: {{users}}",
|
||||
"loadingUsers": "Loading…",
|
||||
"pinDone": {
|
||||
"own-login": "{{user}} signed in with their own login, which was left alone",
|
||||
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
|
||||
},
|
||||
"edit": "Edit {{name}}",
|
||||
"editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.",
|
||||
"save": "Save",
|
||||
"users": "Users on this account",
|
||||
"noUsers": "No users yet.",
|
||||
"onAccount": "now on {{account}}",
|
||||
"editDone": "{{name}} saved",
|
||||
"profilesLink": "Profiles in Settings",
|
||||
"addFor": "Add {{tool}} account",
|
||||
"type": "Type",
|
||||
"about": {
|
||||
"claude": "New Claude Code sessions run on these accounts. A setup-token goes in as an environment variable; a real login is written into each user's home by the server and refreshed by it.",
|
||||
"codex": "New Codex sessions (not on an AI Proxy) run on these accounts. An OpenAI API key goes in as an environment variable; a ChatGPT login is written into each user's own Codex home.",
|
||||
"cursor": "New Cursor CLI sessions run on these Cursor API keys, given to the session as CURSOR_API_KEY — it outranks the user's own `cursor-agent login` for that session only. Cursor reports no usage or quota here, so the account with the fewest live sessions is picked."
|
||||
},
|
||||
"notInstalled": "{{tool}} is not installed on this server yet.",
|
||||
"emptyFor": "No {{tool}} accounts yet. Until you add one, each user's {{tool}} session needs that user's own sign-in.",
|
||||
"kindShort": {
|
||||
"token": "setup-token",
|
||||
"login": "Login",
|
||||
"apikey": "API key",
|
||||
"chatgpt": "ChatGPT login"
|
||||
},
|
||||
"omp": {
|
||||
"badgeOn": "shared",
|
||||
"about": "Oh My Pi has no account pool: it shares this server's own omp login instead. Sign the host account in with `omp` (then /login), turn sharing on, and every user's omp session runs on it through omp's own gateway — with a key that works only while its pane runs.",
|
||||
"off": "Sharing is off: each user's omp session uses that user's own login.",
|
||||
"unavailable": "Oh My Pi has no account pool. Its login can be shared only on a server where every user has their own Linux account (server mode with per-user accounts); here each user's omp session uses that user's own login."
|
||||
},
|
||||
"none": {
|
||||
"title": "{{tool}} has no account pool",
|
||||
"reason": {
|
||||
"antigravity": "Antigravity signs in with a Google account and keeps that login in the system keyring; it takes no key AgentHub could hand a session. Each user signs in to agy themselves.",
|
||||
"default": "AgentHub cannot hand {{tool}} a shared login, so each user signs in to it themselves."
|
||||
}
|
||||
},
|
||||
"liveCount_one": "{{count}} live",
|
||||
"liveCount_other": "{{count}} live",
|
||||
"savedProfiles": {
|
||||
"title": "Saved profiles you can share",
|
||||
"empty": "No saved profiles for this tool. Save one in Settings → AI tools → Profiles, then share it here.",
|
||||
"activeHere": "in use on this server",
|
||||
"share": "Share to pool",
|
||||
"shareNamed": "Share {{name}} to the pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
"description": "Give each team its own accounts: a group's members run only on the group's accounts. Users in no group share the accounts no group lists.",
|
||||
"new": "New group",
|
||||
"empty": "No groups yet — every user shares every account.",
|
||||
"members_one": "{{count}} member",
|
||||
"members_other": "{{count}} members",
|
||||
"edit": "Edit group {{name}}",
|
||||
"delete": "Delete group {{name}}",
|
||||
"deleteHint": "Its members go back to the shared pool. No account is removed.",
|
||||
"noAccounts": "none — members sign in themselves",
|
||||
"saved": "Group {{name}} saved",
|
||||
"deleted": "Group {{name}} deleted",
|
||||
"hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.",
|
||||
"name": "Group name",
|
||||
"memberList": "Members",
|
||||
"inGroup": "now in {{group}}",
|
||||
"accountList": "Accounts",
|
||||
"noneInPool": "No accounts of this tool in the pool.",
|
||||
"shared": "Shared pool"
|
||||
},
|
||||
"window": {
|
||||
"5h": "5 hours",
|
||||
"wk": "Week"
|
||||
},
|
||||
"resetsIn": "resets in {{time}} · {{at}}"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
@@ -239,5 +320,15 @@
|
||||
"openHome": "Open home in Files",
|
||||
"copyUsername": "Copy username",
|
||||
"copyEmail": "Copy email"
|
||||
},
|
||||
"mustChange": "New password due",
|
||||
"mustChangeHint": "Signed in with a password an admin gave; must choose their own before using AgentHub",
|
||||
"passwordRules": {
|
||||
"length": "At least {{min}} characters",
|
||||
"lower": "A lower-case letter",
|
||||
"upper": "An upper-case letter",
|
||||
"digit": "A digit",
|
||||
"symbol": "A symbol (e.g. ! - _ @)",
|
||||
"username": "Does not contain the username"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
|
||||
"deleteConfirm": "Xoá",
|
||||
"installTitle": "Cài “{{title}}”?",
|
||||
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ của chính bạn. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
||||
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ của chính bạn, để mọi AI tool và mọi phiên đều dùng được. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
||||
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
|
||||
"installConfirm": "Cài",
|
||||
"replaceTitle": "Thay skill “{{name}}” của bạn?",
|
||||
@@ -70,7 +70,8 @@
|
||||
"form.save": "Thêm",
|
||||
"form.cancel": "Huỷ",
|
||||
"added": "Đã thêm",
|
||||
"files": "{{count}} file",
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} file",
|
||||
"close": "Đóng",
|
||||
"fromSession": "Từ một phiên",
|
||||
"form.saveEdit": "Lưu",
|
||||
@@ -78,7 +79,7 @@
|
||||
"skill.tab.write": "Viết mới",
|
||||
"skill.tab.existing": "Từ skill có sẵn",
|
||||
"skill.name": "Tên skill",
|
||||
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
|
||||
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ (và ~/.agents/skills/) và gọi bằng /{{slug}}",
|
||||
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
|
||||
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
|
||||
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
|
||||
@@ -110,5 +111,77 @@
|
||||
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
|
||||
"file.upload": "Tải lên",
|
||||
"file.tooBig": "File lớn hơn 25 MB.",
|
||||
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi"
|
||||
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi",
|
||||
"editTitle": "Sửa “{{title}}”",
|
||||
"edit.title": "Tiêu đề",
|
||||
"edit.description": "Mô tả",
|
||||
"previewFailed": "Không tải được file này.",
|
||||
"truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.",
|
||||
"section.hub.own": "Tôi đã share",
|
||||
"section.hub.ownHint": "Của bạn, mọi người dùng đều thấy",
|
||||
"section.hub.others": "Người khác share",
|
||||
"section.hub.othersHint": "Những gì người dùng khác đã share — cài cái bạn cần",
|
||||
"section.mine.own": "Của tôi",
|
||||
"section.mine.ownHint": "Những gì bạn đã thêm",
|
||||
"section.mine.others": "Đã cài từ người khác",
|
||||
"section.mine.othersHint": "Người dùng khác share, đã cài vào tài khoản của bạn",
|
||||
"section.all.others": "Người khác share",
|
||||
"section.all.othersHint": "Những gì người dùng khác đã share",
|
||||
"section.all.own": "Của tôi",
|
||||
"section.all.ownHint": "Những gì bạn đã thêm, đã share hoặc chưa",
|
||||
"pages": "Trang",
|
||||
"section.empty": "Chưa có gì.",
|
||||
"tab.all": "Tất cả",
|
||||
"installedBadge": "Đã cài",
|
||||
"installsTitle": "Lượt cài",
|
||||
"installsYou": "Bạn",
|
||||
"installsUnknown": "Người dùng đã xoá",
|
||||
"installsReplaced": "thay bản cũ",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||
"uninstall": "Gỡ cài đặt",
|
||||
"uninstallTitle": "Gỡ cài đặt “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.",
|
||||
"uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.",
|
||||
"uninstalled": "Đã gỡ {{name}}",
|
||||
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
|
||||
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
|
||||
"hubOnly": "Chỉ trên Hub",
|
||||
"hubOnlyHint": "Bạn đã xoá khỏi thư viện; mục này vẫn được chia sẻ trên Hub",
|
||||
"hubRemove": "Gỡ khỏi Hub",
|
||||
"hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?",
|
||||
"hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.",
|
||||
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bạn gỡ cài đặt rồi cài lại.",
|
||||
"preview.instructions": "Hướng dẫn",
|
||||
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
|
||||
"preview.whenUsed": "Khi nào Claude dùng",
|
||||
"preview.noDescription": "Chưa có mô tả",
|
||||
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/",
|
||||
"blurb.skill": "Bộ hướng dẫn tái sử dụng cho Claude",
|
||||
"blurb.plugin": "Gói skill, lệnh và hook",
|
||||
"blurb.page": "Trang mà phiên đã publish lên claude.ai",
|
||||
"blurb.file": "Tài liệu và kết quả đáng giữ lại",
|
||||
"clearSearch": "Xoá tìm kiếm",
|
||||
"categories": "Danh mục",
|
||||
"seeAll": "Xem tất cả",
|
||||
"results_one": "{{count}} kết quả",
|
||||
"results_other": "{{count}} kết quả",
|
||||
"empty.title": "Thư viện đang trống",
|
||||
"empty.hubTitle": "Hub chưa có gì",
|
||||
"empty.searchTitle": "Không tìm thấy",
|
||||
"empty.search": "Không có mục nào khớp tìm kiếm hoặc danh mục này — thử từ khác, hoặc chọn Tất cả.",
|
||||
"details": "Chi tiết",
|
||||
"more": "Thao tác khác",
|
||||
"about": "Giới thiệu",
|
||||
"stat.kind": "Loại",
|
||||
"stat.installs": "Lượt cài",
|
||||
"stat.files": "File",
|
||||
"stat.size": "Dung lượng",
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}"
|
||||
}
|
||||
|
||||
@@ -198,7 +198,15 @@
|
||||
"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}})"
|
||||
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})",
|
||||
"mustChangeTitle": "Đặt mật khẩu của bạn",
|
||||
"mustChangeSubtitle": "Quản trị viên đã cấp cho bạn mật khẩu ban đầu. Hãy đặt mật khẩu riêng trước khi tiếp tục.",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"confirmPassword": "Nhập lại mật khẩu mới",
|
||||
"mismatch": "Hai mật khẩu mới không khớp",
|
||||
"mustChangeSubmit": "Lưu và tiếp tục",
|
||||
"saving": "Đang lưu…"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
|
||||
@@ -220,6 +220,15 @@
|
||||
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
||||
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
|
||||
"clone.sshSettings": "Mở Cài đặt → Git",
|
||||
"clone.partial": "Clone một phần (nhanh hơn với repo lớn)",
|
||||
"clone.partialHint": "Tải toàn bộ commit nhưng chỉ tải nội dung file khi cần checkout (--filter=blob:none). Checkout một phiên bản cũ sau này sẽ tải thêm từ remote.",
|
||||
"clone.queued": "Đang chờ trong hàng clone — thứ {{position}}/{{waiting}} ({{running}}/{{limit}} clone đang chạy)",
|
||||
"clone.connecting": "Đang kết nối…",
|
||||
"clone.phase.remote": "Máy chủ đang chuẩn bị",
|
||||
"clone.phase.receiving": "Đang tải về",
|
||||
"clone.phase.resolving": "Đang giải nén",
|
||||
"clone.phase.checkout": "Đang ghi file",
|
||||
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
@@ -298,6 +307,7 @@
|
||||
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
|
||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
|
||||
"changes.undoCommitConfirm": "Hoàn tác commit",
|
||||
"changes.undoCommitPushed": "Commit cuối đã có trên nhánh remote (theo lần fetch gần nhất); hoàn tác sẽ viết lại lịch sử đã chia sẻ. Hãy Revert — hoặc Fetch trước nếu remote không còn commit này.",
|
||||
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
|
||||
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
|
||||
"changes.amendTitle": "Amend commit cuối?",
|
||||
@@ -421,6 +431,9 @@
|
||||
"home.cloneRun": "Clone",
|
||||
"home.cloneRetry": "Thử lại các repo lỗi",
|
||||
"home.cloneDone": "Đã thêm",
|
||||
"home.cloneNeedSsh": "Link SSH cần SSH key trước — tài khoản của bạn chưa có. Hãy tạo key trong Cài đặt → Git và thêm public key vào {{hosts}} trước khi clone.",
|
||||
"home.cloneNeedLogin": "Chưa lưu login cho {{hosts}}. Repo private cần username và access token của bạn (hoặc lưu sẵn trong Cài đặt → Git).",
|
||||
"home.cloneEnterLogin": "Nhập login",
|
||||
"home.suggested": "Worktree chưa theo dõi",
|
||||
"home.remove": "Gỡ",
|
||||
"home.missing": "không còn",
|
||||
@@ -518,6 +531,9 @@
|
||||
"changes.unversionedGroup": "File chưa theo dõi",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit và Push",
|
||||
"changes.discardAll": "Bỏ tất cả",
|
||||
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
|
||||
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
|
||||
"changes.amendAndPush": "Amend và Push",
|
||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||
@@ -548,6 +564,9 @@
|
||||
"fx.refresh": "Làm mới",
|
||||
"fx.newFolder": "Thư mục mới",
|
||||
"fx.newFile": "File mới",
|
||||
"fx.cloneRepo": "Clone repository",
|
||||
"fx.cloneSubmit": "Clone",
|
||||
"fx.cloned": "Đã clone vào {{path}} — đã mở thành workspace “{{workspace}}”.",
|
||||
"fx.uploadFiles": "Tải file lên",
|
||||
"fx.uploadFolder": "Tải cả thư mục lên",
|
||||
"fx.download": "Tải về",
|
||||
@@ -631,6 +650,8 @@
|
||||
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
|
||||
"fx.myHome": "Thư mục home",
|
||||
"fx.userHomes": "Home của các user",
|
||||
"fx.users": "Người dùng",
|
||||
"fx.toggleWorkspaces": "Hiện/ẩn workspace của {{name}}",
|
||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
|
||||
"fx.readOnly": "Chỉ đọc",
|
||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Cài đặt → AI tools → Hồ sơ đăng nhập để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||
"quotaWindow": {
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
|
||||
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
|
||||
"bypass": "Bỏ qua xin quyền cho lần chạy này",
|
||||
"yolo": "YOLO",
|
||||
"yoloHint": "Chế độ YOLO cho phiên này: không sandbox, không hỏi phê duyệt (--dangerously-bypass-approvals-and-sandbox)",
|
||||
"bypassShort": "Bỏ qua quyền",
|
||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||
"permissionMode": "Chế độ quyền",
|
||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||
@@ -68,6 +71,8 @@
|
||||
"paste": "Thả để dán vào terminal",
|
||||
"uploading": "Đang tải lên…",
|
||||
"failed": "Không đính kèm được file",
|
||||
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
||||
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
||||
"remove": "Bỏ đính kèm",
|
||||
"view": "Xem ảnh"
|
||||
},
|
||||
@@ -84,6 +89,7 @@
|
||||
"findClose": "Đóng tìm kiếm"
|
||||
},
|
||||
"chat": {
|
||||
"codexPicker": "Mở bộ chọn của chính Codex — trả lời ngay trong chat",
|
||||
"placeholder": "Viết tin nhắn…",
|
||||
"send": "Gửi",
|
||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||
@@ -94,6 +100,9 @@
|
||||
"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",
|
||||
"pickLocal": "Chọn file trên máy này",
|
||||
"pickLocalHint": "Đính kèm file trên máy này bằng đường dẫn — không tải lên gì cả",
|
||||
"pickLocalOpen": "Hộp chọn file đang mở trên màn hình máy này…",
|
||||
"projectFiles": "File trong dự án",
|
||||
"contextFiles": "File ngữ cảnh",
|
||||
"recentUploads": "Đã tải lên gần đây",
|
||||
@@ -131,7 +140,28 @@
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Nhập câu trả lời của bạn…",
|
||||
"cancel": "Huỷ (Esc)"
|
||||
"cancel": "Huỷ (Esc)",
|
||||
"prev": "Câu trước",
|
||||
"next": "Câu tiếp"
|
||||
},
|
||||
"request": {
|
||||
"notePlaceholder": "Nói cho model biết nên làm gì thay vào đó…",
|
||||
"send": "Gửi",
|
||||
"denyWithNote": "Từ chối kèm lời nhắn"
|
||||
},
|
||||
"artifact": {
|
||||
"preview": "Xem trước",
|
||||
"code": "Mã",
|
||||
"copy": "Sao chép",
|
||||
"download": "Tải xuống",
|
||||
"share": "Lưu vào Artifacts",
|
||||
"shared": "Đã lưu vào thư viện Artifacts của bạn — chia sẻ từ trang Artifacts.",
|
||||
"cannotShare": "Không lưu được file này vào Artifacts.",
|
||||
"expand": "Toàn màn hình",
|
||||
"collapse": "Thoát toàn màn hình",
|
||||
"older": "Phiên bản cũ hơn",
|
||||
"newer": "Phiên bản mới hơn",
|
||||
"version": "Phiên bản {{n}}/{{total}}"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -210,7 +210,36 @@
|
||||
"moreSteps_other": "+{{count}} việc nữa",
|
||||
"quiet": "không có hoạt động mới trong {{time}}",
|
||||
"askDeclined": "Đã từ chối — không trả lời",
|
||||
"turnDone": "Xong trong {{time}}"
|
||||
"turnDone": "Xong trong {{time}}",
|
||||
"turnReplied": "Đã trả lời trong {{time}}",
|
||||
"agentsStill_one": "còn {{count}} agent đang chạy",
|
||||
"agentsStill_other": "còn {{count}} agent đang chạy",
|
||||
"agentsRunning_one": "{{count}} agent đang chạy nền",
|
||||
"agentsRunning_other": "{{count}} agent đang chạy nền",
|
||||
"agentContext": "{{tokens}} token",
|
||||
"agent": {
|
||||
"open": "Xem hội thoại",
|
||||
"prompt": "Nhiệm vụ",
|
||||
"background": "chạy nền",
|
||||
"toolUses_one": "{{count}} lần gọi tool",
|
||||
"toolUses_other": "{{count}} lần gọi tool",
|
||||
"status": {
|
||||
"running": "đang chạy",
|
||||
"done": "xong",
|
||||
"error": "lỗi",
|
||||
"stopped": "đã dừng"
|
||||
},
|
||||
"hide": "Ẩn hội thoại"
|
||||
},
|
||||
"artifact": {
|
||||
"html": "Trang web",
|
||||
"svg": "Ảnh · SVG",
|
||||
"markdown": "Tài liệu",
|
||||
"mermaid": "Sơ đồ",
|
||||
"react": "Component React",
|
||||
"csv": "Bảng",
|
||||
"open": "Bấm để mở"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -77,26 +77,10 @@
|
||||
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": [
|
||||
"chào buổi sáng! ☀️",
|
||||
"sáng rồi! cà phê chưa? ☕",
|
||||
"ngày mới tốt lành nha 🌤️"
|
||||
],
|
||||
"afternoon": [
|
||||
"chào buổi chiều! 🐾",
|
||||
"bạn quay lại rồi 😺",
|
||||
"chiều nay làm gì đây? ✨"
|
||||
],
|
||||
"evening": [
|
||||
"chào buổi tối! 🌆",
|
||||
"ca tối hả? có mình đây 😼",
|
||||
"tối rồi, cố lên nhé 🌙"
|
||||
],
|
||||
"night": [
|
||||
"khuya rồi mà vẫn làm à? 🌙",
|
||||
"để mình canh giúp cho 🦉",
|
||||
"nhớ nghỉ ngơi nha 💤"
|
||||
],
|
||||
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
|
||||
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
|
||||
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
|
||||
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
|
||||
"summary_one": "{{count}} phiên đang chạy",
|
||||
"summary_other": "{{count}} phiên đang chạy",
|
||||
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||
@@ -114,11 +98,7 @@
|
||||
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
||||
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
||||
"hello": "chào bạn! 😺 {{summary}}.",
|
||||
"thanks": [
|
||||
"có gì đâu! 💜",
|
||||
"rất vui được giúp 😸",
|
||||
"chuyện nhỏ 🐾"
|
||||
],
|
||||
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
|
||||
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||
@@ -175,41 +155,13 @@
|
||||
"không gọi được máy chủ 📡",
|
||||
"mất sóng - đi ngủ chút 💤"
|
||||
],
|
||||
"worried": [
|
||||
"có vẻ không ổn 😟",
|
||||
"mình đang để mắt đây 👀",
|
||||
"hừm, có gì đó lạ lạ 🫣"
|
||||
],
|
||||
"stuck": [
|
||||
"một phiên im lặng lâu rồi… 🤔",
|
||||
"có gì bị kẹt không? ⏳",
|
||||
"vẫn đang nhai… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"tuyệt vời! 😻",
|
||||
"kết thúc gọn ghê ✨",
|
||||
"hoàn hảo 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"để mình xem… 🤔",
|
||||
"đang đánh hơi… 🐾",
|
||||
"chờ chút, đang kiểm tra 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"đang tuần tra 👀",
|
||||
"đang canh các phiên của bạn 😼",
|
||||
"mắt mở to 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"có gì thì gọi mình dậy nhé 😴",
|
||||
"cuộn tròn, mọi thứ yên ổn 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"yên ắng quá 😺",
|
||||
"sẵn sàng khi bạn cần 🐾",
|
||||
"chill thôi ✨"
|
||||
]
|
||||
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
|
||||
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
|
||||
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
|
||||
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
|
||||
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
||||
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
|
||||
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Trò chuyện với {{name}}",
|
||||
@@ -1140,6 +1092,21 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Dùng chung đăng nhập Oh My Pi",
|
||||
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
||||
"hint": "Mọi session omp của người dùng chạy bằng các đăng nhập mà tài khoản máy chủ đã tạo bằng `omp login`, qua auth broker và gateway của chính omp trên máy này. Session chỉ nhận một key dùng được khi pane còn chạy, không bao giờ nhận chính đăng nhập. Chỉ các provider có sẵn của omp được dùng chung.",
|
||||
"running": "Đang chạy: broker và gateway đã sẵn sàng.",
|
||||
"starting": "Đang khởi động…",
|
||||
"notInstalled": "Máy chủ chưa cài omp.",
|
||||
"failed": "Không chạy: {{error}}"
|
||||
},
|
||||
"markdownView": {
|
||||
"label": "Mở file Markdown dạng",
|
||||
"description": "Cách file .md mở trong Files, Changes, trình xem cấu hình và bảng file của chat. Nút Xem trước/Mã nguồn vẫn đổi riêng file đó. Lưu theo tài khoản.",
|
||||
"preview": "Xem trước",
|
||||
"raw": "Mã nguồn"
|
||||
},
|
||||
"workspaceTabs": {
|
||||
"title": "Tab của workspace",
|
||||
"description": "Thanh tab của màn hình session chứa những session nào. Lưu theo tài khoản.",
|
||||
@@ -1302,11 +1269,25 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Giao diện chat",
|
||||
"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.",
|
||||
"description": "Cách một session Claude, Codex, omp, Cursor hoặc Antigravity 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.",
|
||||
"labelDesc": "Session mới sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal (terminal vẫn chạy bên dưới). 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à câu hỏi được trả lời bằng thẻ ngay trong chat.",
|
||||
"saveFailed": "Không lưu được: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
"title": "Mặc định cho phiên mới",
|
||||
"description": "Phiên mới bắt đầu với các lựa chọn này trong ô prompt của workspace. Chỉ lưu cho tài khoản của bạn; chọn khác trong ô prompt chỉ đổi cho phiên đó.",
|
||||
"claudeMode": "Chế độ quyền của Claude Code",
|
||||
"claudeModeDesc": "Bỏ qua quyền cho mọi phiên Claude mới chạy mọi tool mà không hỏi.",
|
||||
"codexSandbox": "Sandbox của Codex",
|
||||
"codexApproval": "Phê duyệt của Codex",
|
||||
"codexBypass": "Codex: chế độ YOLO",
|
||||
"codexBypassDesc": "Mọi phiên Codex mới chạy ở chế độ YOLO — `--dangerously-bypass-approvals-and-sandbox`: không sandbox, không hỏi phê duyệt.",
|
||||
"confirmTitle": "Đặt bỏ qua quyền làm mặc định cho {{tool}}?",
|
||||
"confirmBody": "Mọi phiên {{tool}} mới bạn mở sẽ chạy lệnh và sửa file mà không hỏi, cho tới khi bạn đổi lại. Chỉ dùng với thư mục và prompt bạn tin tưởng.",
|
||||
"confirmLabel": "Đặt làm mặc định",
|
||||
"codexConfirmTitle": "Đặt YOLO làm mặc định cho Codex?"
|
||||
},
|
||||
"agentInstructions": {
|
||||
"title": "Hướng dẫn agent của tôi",
|
||||
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
|
||||
@@ -1325,7 +1306,11 @@
|
||||
"remove": "Xoá ảnh đại diện",
|
||||
"saved": "Đã cập nhật ảnh đại diện",
|
||||
"removed": "Đã xoá ảnh đại diện",
|
||||
"tooBig": "Ảnh tối đa 1 MB"
|
||||
"tooBig": "Ảnh tối đa 1 MB",
|
||||
"fromUrl": "Dùng ảnh từ đường dẫn",
|
||||
"urlPlaceholder": "https://… đường dẫn ảnh",
|
||||
"urlApply": "Dùng",
|
||||
"linkUnreachable": "Trình duyệt không tải được ảnh từ link này — có thể link cần đăng nhập (ảnh Mattermost hay app chat khác là vậy). Hãy tải ảnh về rồi upload lên."
|
||||
},
|
||||
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||
"memberSince": "Tham gia từ",
|
||||
@@ -1353,5 +1338,32 @@
|
||||
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
|
||||
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
|
||||
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
|
||||
},
|
||||
"toolProfiles": {
|
||||
"title": "Hồ sơ đăng nhập",
|
||||
"description": "Mọi đăng nhập đã lưu của các AI tool ở một nơi. Khi đổi chỉ phần đăng nhập được hoán đổi; config, skills, phiên làm việc và lịch sử vẫn giữ nguyên.",
|
||||
"poolCount_one": "Account pool đang có {{count}} tài khoản cho tool này.",
|
||||
"poolCount_other": "Account pool đang có {{count}} tài khoản cho tool này.",
|
||||
"openPool": "Mở account pool",
|
||||
"moveToPool": "Đưa vào pool",
|
||||
"moveToPoolHint": "Quản trị → Người dùng → Account pool, chọn sẵn hồ sơ này. Pool sẽ là nơi duy nhất giữ đăng nhập này và hồ sơ được xoá.",
|
||||
"saveButton": "Lưu đăng nhập này thành hồ sơ",
|
||||
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||
"subtitle": {
|
||||
"codex": "Mỗi hồ sơ là một đăng nhập Codex (tài khoản ChatGPT hoặc API key): toàn bộ auth.json. config.toml, phiên, skills và lịch sử vẫn giữ nguyên.",
|
||||
"omp": "Mỗi hồ sơ là toàn bộ các đăng nhập Oh My Pi đang lưu (mọi provider), đọc bằng `omp auth export` và ghi lại bằng `omp auth import`. Settings, model và phiên vẫn giữ nguyên. Các phiên omp đang chạy tự nhận đăng nhập mới."
|
||||
},
|
||||
"unsupported": {
|
||||
"antigravity": "Antigravity lưu đăng nhập trong keyring của hệ điều hành, AgentHub không đọc hay ghi vào đó nên không đổi được từ đây. Hãy đăng nhập/đăng xuất ngay trong agy."
|
||||
},
|
||||
"kind": {
|
||||
"chatgpt": "Đăng nhập ChatGPT",
|
||||
"apikey": "API key",
|
||||
"credentials": "Đăng nhập"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,5 +35,6 @@
|
||||
"autoFix": "Tự sửa",
|
||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||
"autoFixError": "Không mở được phiên Tự sửa"
|
||||
"autoFixError": "Không mở được phiên Tự sửa",
|
||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng."
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Không giới hạn độ dài tối thiểu",
|
||||
"passwordHint": "Mật khẩu ban đầu tuỳ ý — người dùng sẽ tự đặt mật khẩu riêng ở lần đăng nhập đầu",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"changeRole": "Đổi vai trò",
|
||||
"disable": "Khoá",
|
||||
@@ -68,6 +68,9 @@
|
||||
"errors": {
|
||||
"EBADIMAGE": "Chỉ nhận ảnh PNG, JPEG, GIF hoặc WebP",
|
||||
"ETOOBIG": "Ảnh tối đa 1 MB",
|
||||
"EBADURL": "Hãy nhập đường dẫn https:// tới một ảnh.",
|
||||
"EPRIVATEURL": "Đường dẫn trỏ tới địa chỉ nội bộ hoặc cục bộ.",
|
||||
"EFETCH": "Không tải được ảnh từ đường dẫn này.",
|
||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
|
||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||
@@ -81,7 +84,9 @@
|
||||
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
|
||||
"EBADROLE": "Vai trò không hợp lệ.",
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
||||
"ELASTADMIN": "Không thể xoá admin cuối cùng."
|
||||
"ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.",
|
||||
"EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).",
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
@@ -107,7 +112,7 @@
|
||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi.",
|
||||
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi và họ phải tự đặt mật khẩu riêng ở lần đăng nhập sau.",
|
||||
"show": "Hiện mật khẩu",
|
||||
"hide": "Ẩn mật khẩu",
|
||||
"copy": "Sao chép mật khẩu",
|
||||
@@ -122,16 +127,15 @@
|
||||
"sessions": "Phiên đăng nhập",
|
||||
"signOut": "Đăng xuất ở mọi nơi",
|
||||
"signOutHint": "Kết thúc {{n}} phiên đăng nhập đang hoạt động của người này; họ đăng nhập lại bằng mật khẩu.",
|
||||
"noChanges": "Chưa có thay đổi."
|
||||
"noChanges": "Chưa có thay đổi.",
|
||||
"ownPasswordHint": "Để trống để giữ mật khẩu hiện tại. Các phiên đăng nhập khác của bạn sẽ kết thúc."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
"description": "Các tài khoản Claude và Codex mà phiên mới của mọi user chạy trên đó — tự chọn tài khoản ít tải nhất, server ghi login vào home của user, không ai phải đăng nhập riêng.",
|
||||
"description": "Các tài khoản mà phiên mới của từng tool chạy trên đó, mỗi AI tool một tab. Tự chọn tài khoản ít tải nhất (hoặc tài khoản user được gán), không ai phải đăng nhập riêng.",
|
||||
"refresh": "Đọc lại quota",
|
||||
"add": "Thêm tài khoản",
|
||||
"empty": "Chưa có tài khoản nào. Khi chưa thêm, phiên của mỗi người dùng cần chính người đó đăng nhập.",
|
||||
"account": "Tài khoản",
|
||||
"tool": "Công cụ",
|
||||
"livePanes": "Đang chạy",
|
||||
"quota": "Quota",
|
||||
"spend": "5 giờ",
|
||||
@@ -140,12 +144,6 @@
|
||||
"added": "thêm lúc {{at}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "đầy",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Tên",
|
||||
"nameHint": "Nhãn tuỳ ý để bạn nhận ra: chữ, số, '.', '_' hoặc '-'",
|
||||
"remove": "Xoá {{name}}",
|
||||
@@ -166,37 +164,120 @@
|
||||
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
||||
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
|
||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
|
||||
"ENOUSER": "User đó không còn tồn tại.",
|
||||
"ENOGROUP": "Nhóm đó không còn tồn tại."
|
||||
},
|
||||
"source": "Nguồn",
|
||||
"sources": {
|
||||
"profile": "Login đã lưu (profile Switch account)",
|
||||
"profile": "Hồ sơ đã lưu (Cài đặt → AI tools → Hồ sơ đăng nhập)",
|
||||
"credentials": "Dán .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
||||
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường."
|
||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường.",
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT.",
|
||||
"cursor": "Cursor API key lấy từ Cursor dashboard (Integrations → API keys)."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Lưu ở Settings → Claude config → Switch account. Profile mà Claude Code của server đang dùng cũng chuyển được: server vẫn chạy trên nó và tự làm mới cho mọi người.",
|
||||
"profileHint": "Lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập. Hồ sơ mà CLI của server đang dùng cũng chuyển được: server vẫn chạy trên nó.",
|
||||
"chooseProfile": "Chọn login đã lưu…",
|
||||
"noProfiles": "Chưa có login nào — lưu ở Settings → Claude config → Switch account",
|
||||
"noProfiles": "Chưa có hồ sơ nào — lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập",
|
||||
"loadingProfiles": "Đang tải…",
|
||||
"activeHere": "server đang dùng — vẫn chạy tiếp",
|
||||
"nameFromProfile": "Để trống để dùng tên profile",
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key hoặc auth.json"
|
||||
"codex": "API key hoặc auth.json",
|
||||
"cursor": "Cursor API key"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "Toàn bộ file: {\"claudeAiOauth\": {…}}",
|
||||
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT.",
|
||||
"cursor": "Tạo trong Cursor dashboard tại Integrations → API keys."
|
||||
},
|
||||
"refreshFailed": "làm mới lỗi"
|
||||
"refreshFailed": "làm mới lỗi",
|
||||
"pinned": "Đang dùng: {{users}}",
|
||||
"loadingUsers": "Đang tải…",
|
||||
"pinDone": {
|
||||
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
|
||||
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
|
||||
},
|
||||
"edit": "Sửa {{name}}",
|
||||
"editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.",
|
||||
"save": "Lưu",
|
||||
"users": "User dùng tài khoản này",
|
||||
"noUsers": "Chưa có user nào.",
|
||||
"onAccount": "đang ở {{account}}",
|
||||
"editDone": "Đã lưu {{name}}",
|
||||
"profilesLink": "Hồ sơ trong Cài đặt",
|
||||
"addFor": "Thêm tài khoản {{tool}}",
|
||||
"type": "Loại",
|
||||
"about": {
|
||||
"claude": "Phiên Claude Code mới chạy trên các tài khoản này. Setup-token được đưa vào dưới dạng biến môi trường; login thật được server ghi vào home của từng user và tự làm mới.",
|
||||
"codex": "Phiên Codex mới (không qua AI Proxy) chạy trên các tài khoản này. OpenAI API key được đưa vào dưới dạng biến môi trường; login ChatGPT được ghi vào Codex home riêng của từng user.",
|
||||
"cursor": "Phiên Cursor CLI mới chạy trên các Cursor API key này, đưa vào phiên qua CURSOR_API_KEY — chỉ trong phiên đó nó được ưu tiên hơn `cursor-agent login` của chính user. Cursor không báo mức dùng hay quota ở đây, nên tài khoản có ít phiên đang chạy nhất sẽ được chọn."
|
||||
},
|
||||
"notInstalled": "{{tool}} chưa được cài trên server này.",
|
||||
"emptyFor": "Chưa có tài khoản {{tool}} nào. Khi chưa thêm, phiên {{tool}} của mỗi user cần chính user đó đăng nhập.",
|
||||
"kindShort": {
|
||||
"token": "setup-token",
|
||||
"login": "Login",
|
||||
"apikey": "API key",
|
||||
"chatgpt": "Login ChatGPT"
|
||||
},
|
||||
"omp": {
|
||||
"badgeOn": "chia sẻ",
|
||||
"about": "Oh My Pi không có account pool: thay vào đó nó chia sẻ login omp của chính server. Đăng nhập tài khoản host bằng `omp` (rồi /login), bật chia sẻ, và phiên omp của mọi user chạy trên login đó qua gateway của omp — với một key chỉ dùng được khi pane còn chạy.",
|
||||
"off": "Chia sẻ đang tắt: phiên omp của mỗi user dùng login của chính user đó.",
|
||||
"unavailable": "Oh My Pi không có account pool. Login của nó chỉ chia sẻ được trên server mà mỗi user có tài khoản Linux riêng (server mode với tài khoản theo user); ở đây phiên omp của mỗi user dùng login của chính user đó."
|
||||
},
|
||||
"none": {
|
||||
"title": "{{tool}} không có account pool",
|
||||
"reason": {
|
||||
"antigravity": "Antigravity đăng nhập bằng tài khoản Google và giữ login trong keyring của hệ thống; nó không nhận key nào để AgentHub đưa cho phiên. Mỗi user tự đăng nhập agy.",
|
||||
"default": "AgentHub không thể đưa cho {{tool}} một login dùng chung, nên mỗi user tự đăng nhập."
|
||||
}
|
||||
},
|
||||
"liveCount_one": "{{count}} đang chạy",
|
||||
"liveCount_other": "{{count}} đang chạy",
|
||||
"savedProfiles": {
|
||||
"title": "Profile đã lưu có thể chia sẻ",
|
||||
"empty": "Chưa có profile nào của tool này. Lưu một profile trong Settings → AI tools → Profiles rồi chia sẻ ở đây.",
|
||||
"activeHere": "đang dùng trên server này",
|
||||
"share": "Chia sẻ vào pool",
|
||||
"shareNamed": "Chia sẻ {{name}} vào pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Nhóm",
|
||||
"description": "Mỗi nhóm có tài khoản riêng: thành viên của nhóm chỉ chạy trên tài khoản của nhóm. User không thuộc nhóm nào dùng chung các tài khoản không nằm trong nhóm nào.",
|
||||
"new": "Tạo nhóm",
|
||||
"empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.",
|
||||
"members_one": "{{count}} thành viên",
|
||||
"members_other": "{{count}} thành viên",
|
||||
"edit": "Sửa nhóm {{name}}",
|
||||
"delete": "Xoá nhóm {{name}}",
|
||||
"deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.",
|
||||
"noAccounts": "chưa có — thành viên tự đăng nhập",
|
||||
"saved": "Đã lưu nhóm {{name}}",
|
||||
"deleted": "Đã xoá nhóm {{name}}",
|
||||
"hint": "Tick các thành viên và tài khoản mà họ dùng. Mỗi user thuộc một nhóm, tick một user sẽ chuyển họ sang đây. Một tài khoản có thể dùng cho nhiều nhóm.",
|
||||
"name": "Tên nhóm",
|
||||
"memberList": "Thành viên",
|
||||
"inGroup": "đang ở {{group}}",
|
||||
"accountList": "Tài khoản",
|
||||
"noneInPool": "Pool chưa có tài khoản nào của tool này.",
|
||||
"shared": "Pool chung"
|
||||
},
|
||||
"window": {
|
||||
"5h": "5 giờ",
|
||||
"wk": "Tuần"
|
||||
},
|
||||
"resetsIn": "reset sau {{time}} · {{at}}"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
@@ -239,5 +320,15 @@
|
||||
"openHome": "Mở home trong Files",
|
||||
"copyUsername": "Sao chép username",
|
||||
"copyEmail": "Sao chép email"
|
||||
},
|
||||
"mustChange": "Chờ đổi mật khẩu",
|
||||
"mustChangeHint": "Đang dùng mật khẩu do admin cấp; phải tự đặt mật khẩu riêng trước khi dùng AgentHub",
|
||||
"passwordRules": {
|
||||
"length": "Ít nhất {{min}} ký tự",
|
||||
"lower": "Có chữ thường",
|
||||
"upper": "Có chữ hoa",
|
||||
"digit": "Có chữ số",
|
||||
"symbol": "Có ký tự đặc biệt (vd. ! - _ @)",
|
||||
"username": "Không chứa tên đăng nhập"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file artifactView.test.ts
|
||||
* @description What counts as an artifact and how it is shown: kinds by
|
||||
* name, a React file only when written whole, a file's versions rebuilt from
|
||||
* the conversation's Write/Edit/MultiEdit inputs (a failed call skipped, an
|
||||
* edit to unseen text unknown), CSV rows, and the sandboxed documents.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
artifactDocument,
|
||||
artifactKind,
|
||||
artifactVersions,
|
||||
frameSandbox,
|
||||
isArtifact,
|
||||
parseCsv,
|
||||
} from "../artifactView";
|
||||
import type { TranscriptMessage } from "../types";
|
||||
|
||||
const use = (id: string, name: string, input: Record<string, unknown>): TranscriptMessage => ({
|
||||
type: "assistant",
|
||||
timestamp: `2026-10-21T00:00:0${id}.000Z`,
|
||||
content: [{ type: "tool_use", id, name, input } as TranscriptMessage["content"][number]],
|
||||
});
|
||||
const result = (id: string, isError = false): TranscriptMessage => ({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: null,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id,
|
||||
output: "",
|
||||
is_error: isError,
|
||||
} as TranscriptMessage["content"][number],
|
||||
],
|
||||
});
|
||||
|
||||
describe("artifactView", () => {
|
||||
it("knows an artifact by its name", () => {
|
||||
expect(artifactKind("/a/page.HTML")).toBe("html");
|
||||
expect(artifactKind("d.mmd")).toBe("mermaid");
|
||||
expect(artifactKind("App.tsx")).toBe("react");
|
||||
expect(artifactKind("x.go")).toBe("code");
|
||||
expect(isArtifact("report.md", false)).toBe(true);
|
||||
expect(isArtifact("App.tsx", false)).toBe(false);
|
||||
expect(isArtifact("App.tsx", true)).toBe(true);
|
||||
expect(isArtifact("server.js", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||
const msgs = [
|
||||
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
||||
result("1"),
|
||||
use("2", "Edit", { file_path: "/w/a.md", old_string: "old", new_string: "new" }),
|
||||
result("2"),
|
||||
use("3", "Edit", { file_path: "/w/a.md", old_string: "missing", new_string: "x" }),
|
||||
result("3", true),
|
||||
use("4", "MultiEdit", {
|
||||
file_path: "/w/a.md",
|
||||
edits: [{ old_string: "# A", new_string: "# B" }],
|
||||
}),
|
||||
result("4"),
|
||||
use("5", "Write", { file_path: "/w/other.md", content: "no" }),
|
||||
];
|
||||
const v = artifactVersions(msgs, "/w/a.md");
|
||||
expect(v.map((x) => x.content)).toEqual([
|
||||
"# A\nold line\n",
|
||||
"# A\nnew line\n",
|
||||
"# B\nnew line\n",
|
||||
]);
|
||||
});
|
||||
|
||||
it("says an edit to text it never saw is of unknown content", () => {
|
||||
const v = artifactVersions(
|
||||
[use("1", "Edit", { file_path: "a.md", old_string: "a", new_string: "b" })],
|
||||
"a.md"
|
||||
);
|
||||
expect(v).toHaveLength(1);
|
||||
expect(v[0]!.content).toBeNull();
|
||||
});
|
||||
|
||||
it("reads CSV with quotes and newlines inside them", () => {
|
||||
expect(parseCsv('a,b\n"x, y","say ""hi""\nthere"\n')).toEqual([
|
||||
["a", "b"],
|
||||
["x, y", 'say "hi"\nthere'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds sandboxed documents that keep the source inert inside a script", () => {
|
||||
const doc = artifactDocument("react", "export default () => <p>a</script>b</p>", false)!;
|
||||
expect(doc).toContain("babel");
|
||||
expect(doc).not.toContain("a</script>b");
|
||||
expect(artifactDocument("mermaid", "graph TD; A-->B<img>", true)).toContain(
|
||||
"A-->B<img>"
|
||||
);
|
||||
expect(artifactDocument("code", "x", false)).toBeNull();
|
||||
expect(frameSandbox("svg")).toBe("");
|
||||
expect(frameSandbox("html")).not.toContain("allow-same-origin");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user