Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8bc4a95a9 | |||
| 65a001dad4 | |||
| 572d615d4e | |||
| 9dd97a369c | |||
| 62b4520507 | |||
| 241e19b713 | |||
| 685eb748e8 | |||
| 44426faf16 | |||
| 26952ae0d9 | |||
| 649f0599f8 | |||
| 8d2765313c | |||
| 57f02f0d89 | |||
| 50ac0f0daf | |||
| fa74cce3fc | |||
| 3113479cd2 | |||
| 4eb7734ab9 | |||
| 750d74a2af |
+9
-13
File diff suppressed because one or more lines are too long
+61
-17
@@ -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]|pin <account> <user>|unpin <user> [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
"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,8 +3812,8 @@ 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"],
|
||||
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"],
|
||||
@@ -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":
|
||||
@@ -4317,8 +4328,36 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `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] —
|
||||
* `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) {
|
||||
@@ -4326,7 +4365,9 @@ async function cmdAccountPin(sub, positional, tool) {
|
||||
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]");
|
||||
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 = ?")
|
||||
@@ -4376,14 +4417,15 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
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.")
|
||||
@@ -4439,11 +4481,13 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
);
|
||||
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> | pin <account> <user> | unpin <user> [--tool codex]"
|
||||
"✖ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,15 @@
|
||||
* 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. While hidden — or once the status
|
||||
* stops offering the update mid-run — a small chip in the corner keeps showing
|
||||
* it, with the plugin chain's current step from `plugin_update_progress`, and a
|
||||
* click on it shows the modal again. A chain the server reports as running
|
||||
* (started before a reload or from another tab) is adopted the same way;
|
||||
* "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…)
|
||||
@@ -182,6 +189,8 @@ export function UpdateNotifier() {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
/** The running plugin chain's step, from the polls while it runs. */
|
||||
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
@@ -236,20 +245,24 @@ export function UpdateNotifier() {
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
// Updating replaces the host's install: only an admin is offered it.
|
||||
// Updating replaces the host's install: only an admin is offered it. A
|
||||
// running update stays shown (unless closed) even once the status stops
|
||||
// offering it — the new version may already be on disk before it restarts.
|
||||
const show = Boolean(
|
||||
isAdmin &&
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
closedKey !== dismissKey &&
|
||||
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
|
||||
);
|
||||
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 +274,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;
|
||||
@@ -307,6 +320,7 @@ export function UpdateNotifier() {
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
setProgress(s.plugin_update_progress ?? null);
|
||||
const v = s.last_plugin_update;
|
||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||
@@ -315,6 +329,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
|
||||
@@ -333,9 +348,23 @@ export function UpdateNotifier() {
|
||||
[syncFromPayload, t]
|
||||
);
|
||||
|
||||
// A chain already running on the server (started before a reload, in another
|
||||
// tab, or from the old bundle) is adopted: this tab shows its steps, then
|
||||
// reloads onto the new server like the tab that started it.
|
||||
const running = status?.plugin_update_progress ?? null;
|
||||
const adoptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
|
||||
adoptedRef.current = true;
|
||||
setProgress(running);
|
||||
setRestarting(true);
|
||||
pollUntilBack(true, status?.local_version ?? null);
|
||||
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
|
||||
|
||||
const applyNow = async () => {
|
||||
if (applying || restarting) return;
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setApplying(true);
|
||||
try {
|
||||
const result = await api.updates.apply();
|
||||
@@ -359,6 +388,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
|
||||
@@ -384,6 +415,54 @@ export function UpdateNotifier() {
|
||||
}
|
||||
};
|
||||
|
||||
const stepText = progress?.step
|
||||
? t(`pluginStep.${progress.step}`, {
|
||||
index: progress.index,
|
||||
total: progress.total,
|
||||
defaultValue: progress.step,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Hidden while it runs — closed, or the status no longer offers the update
|
||||
// (the new version is already on disk): a chip keeps it in sight.
|
||||
if (!show && isAdmin && (applying || restarting)) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="update-chip"
|
||||
onClick={() => setClosedKey(null)}
|
||||
title={t("chipHint")}
|
||||
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||||
<span className="truncate">
|
||||
{status?.latest_release
|
||||
? t("chipTitle", { version: status.latest_release })
|
||||
: t("updating")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||||
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||||
</span>
|
||||
{progress && progress.total > 0 ? (
|
||||
<span
|
||||
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress.index}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={progress.total}
|
||||
>
|
||||
<span
|
||||
className="block h-full bg-accent-solid transition-all"
|
||||
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!show || !status) return null;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -436,7 +515,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"
|
||||
>
|
||||
@@ -513,7 +591,14 @@ export function UpdateNotifier() {
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
seconds — saying "restarting" there would look hung. */}
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
<span>
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
{stepText ? (
|
||||
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||||
{stepText}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
|
||||
* server like the other preferences) arrange the list under foldable headers —
|
||||
* each group in its order, then "Ungrouped" — inside each owner group when
|
||||
* there are owner groups. A workspace joins a group from its menu, by a drop
|
||||
* on the group's header, by a drag that lands it below a row of that
|
||||
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
|
||||
* another to reorder the groups, and its menu renames, moves, folds every
|
||||
* group, adds a new workspace straight into it or deletes it (its
|
||||
* workspaces stay, ungrouped). A folded header still shows the worst state
|
||||
* of its workspaces (needs you, error, running) and marks the selected one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -30,7 +40,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
@@ -46,7 +56,31 @@ import {
|
||||
writeLaneOrder,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import {
|
||||
arrangeByGroup,
|
||||
assignGroup,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
groupOf,
|
||||
moveGroup,
|
||||
moveGroupBefore,
|
||||
pruneWorkspaceGroups,
|
||||
renameGroup,
|
||||
setAllFolded,
|
||||
setGroupFolded,
|
||||
useWorkspaceGroups,
|
||||
type WorkspaceGroup,
|
||||
} from "../lib/workspaceGroups";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
|
||||
import {
|
||||
LANE_DRAG_TYPE,
|
||||
WorkspaceRow,
|
||||
isLaneDrag,
|
||||
workspaceDot,
|
||||
workspaceDotLabel,
|
||||
workspaceName,
|
||||
} from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
|
||||
);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||
// "New workspace in this group": the group the added workspace joins.
|
||||
const [addToGroup, setAddToGroup] = useState<string | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [orderTick, setOrderTick] = useState(0);
|
||||
const groups = useWorkspaceGroups();
|
||||
const hasGroups = groups.groups.length > 0;
|
||||
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
|
||||
const [groupMenu, setGroupMenu] = useState<{
|
||||
group: WorkspaceGroup;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
// A removed workspace leaves the stored groups (they hold ids only).
|
||||
const laneIdsKey = lanes.map((l) => l.id).join(",");
|
||||
useEffect(() => {
|
||||
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
|
||||
}, [laneIdsKey]);
|
||||
|
||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||
// status or session moves, so that is when the live-pane list is re-read;
|
||||
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
const openAdd = (sourceRepo?: string) => {
|
||||
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
|
||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||
setAddToGroup(groupId);
|
||||
setAddOpen(true);
|
||||
api.run
|
||||
.cwds()
|
||||
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
|
||||
const toolFilter =
|
||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||
const sorted = useMemo(() => {
|
||||
const list = sortByStoredOrder(lanes, order).filter(
|
||||
(l) => !toolFilter || l.tool === toolFilter
|
||||
const list = arrangeByGroup(
|
||||
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||
groups
|
||||
);
|
||||
if (!grouped) return list;
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
|
||||
return [...list].sort(
|
||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||
);
|
||||
}, [lanes, order, toolFilter, grouped, meId]);
|
||||
}, [lanes, order, toolFilter, grouped, meId, groups]);
|
||||
const q = query.trim().toLowerCase();
|
||||
const nameHit = (l: Lane) =>
|
||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
|
||||
setOrderTick((n) => n + 1);
|
||||
};
|
||||
|
||||
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
|
||||
// screen and joins the next group instead (the arrangement would otherwise
|
||||
// put it straight back): last of the group above, first of the one below —
|
||||
// opened if folded, so the focused row stays shown. The shown order is
|
||||
// written as it stands, so the store agrees. Never across an owner's section.
|
||||
const nudge = (from: number, to: number) => {
|
||||
const lane = sorted[from];
|
||||
const next = sorted[to];
|
||||
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
|
||||
const target = groupOf(groups, next.id);
|
||||
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
|
||||
assignGroup(lane.id, target?.id ?? null);
|
||||
if (target?.folded) setGroupFolded(target.id, false);
|
||||
reorder((ids) => [...ids]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
reorder((ids) => moveLane(ids, from, to));
|
||||
};
|
||||
|
||||
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
||||
// workspace's session list, the space above or below a row — lands too.
|
||||
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
||||
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
|
||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||
// Number("") is 0: an empty payload must not move the first row.
|
||||
const from = raw === "" ? NaN : Number(raw);
|
||||
if (!Number.isInteger(from)) return;
|
||||
const lane = sorted[from];
|
||||
// Dropped on a group's header: into that group, at its top.
|
||||
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
|
||||
if (header && lane) {
|
||||
const gid = header.dataset.groupDrop || null;
|
||||
assignGroup(lane.id, gid);
|
||||
const first = sorted.findIndex(
|
||||
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
|
||||
);
|
||||
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
|
||||
return;
|
||||
}
|
||||
const before = dropIndexAt(e.clientY);
|
||||
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
|
||||
// Elsewhere it joins the group of the row it now sits under (the line
|
||||
// drawn above a header closes the section before it).
|
||||
if (lane && hasGroups) {
|
||||
const rest = sorted.filter((l) => l.id !== lane.id);
|
||||
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
|
||||
const neighbour = rest[at - 1] ?? rest[0];
|
||||
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
|
||||
}
|
||||
reorder((ids) => moveBefore(ids, from, before));
|
||||
},
|
||||
};
|
||||
|
||||
// A group no workspace is in yet still shows (to be renamed, or dropped on),
|
||||
// just above the ungrouped ones; a search hides it.
|
||||
const emptyGroups = q
|
||||
? []
|
||||
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
|
||||
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
|
||||
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
|
||||
<GroupHeader
|
||||
key={g?.id ?? "none"}
|
||||
group={g}
|
||||
count={members.length}
|
||||
// What a folded group hides: the most urgent of its workspaces, and
|
||||
// whether the open one is among them.
|
||||
worst={worstOf(members)}
|
||||
holdsSelected={members.some((l) => l.id === selected.lane)}
|
||||
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
|
||||
folded={!!g?.folded && !q}
|
||||
renaming={!!g && renamingGroup === g.id}
|
||||
first={first}
|
||||
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
|
||||
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
|
||||
onRenameEnd={(name) => {
|
||||
setRenamingGroup(null);
|
||||
if (g && name !== null) renameGroup(g.id, name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const newGroup = (laneIds: number[] = []) => {
|
||||
const id = createGroup(t("tree.groupDefaultName"), laneIds);
|
||||
setRenamingGroup(id);
|
||||
};
|
||||
const removeGroup = async (g: WorkspaceGroup) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("tree.groupDelete"),
|
||||
message: t("tree.groupDeleteConfirm", { name: g.name }),
|
||||
confirmLabel: t("tree.groupDelete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
deleteGroup(g.id);
|
||||
};
|
||||
|
||||
const rename = async (lane: Lane, title: string | null) => {
|
||||
setRenamingId(null);
|
||||
if (title === null || title === (lane.title || "")) return;
|
||||
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-tree-new-group"
|
||||
onClick={() => newGroup()}
|
||||
aria-label={t("tree.newGroup")}
|
||||
title={t("tree.newGroup")}
|
||||
className={headBtn}
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-tree-add"
|
||||
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
|
||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
// The user's own group, inside the owner's.
|
||||
const ug = groupOf(groups, lane.id);
|
||||
const ugKey = ug?.id ?? "";
|
||||
const sameSection = (l: Lane) =>
|
||||
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
|
||||
const prev = sorted[i - 1];
|
||||
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
|
||||
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
|
||||
const sectionFolded = !!ug?.folded && !q;
|
||||
const hiddenRow = folded || sectionFolded;
|
||||
return (
|
||||
<div key={lane.id} className="relative">
|
||||
{groupStart && groupShown && (
|
||||
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
|
||||
{sectionStart &&
|
||||
sectionShown &&
|
||||
!folded &&
|
||||
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && !folded && (
|
||||
{visible(lane) && !hiddenRow && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onReorder={nudge}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && !folded && (
|
||||
{expanded(lane.id) && !hiddenRow && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{firstUngrouped === -1 &&
|
||||
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
|
||||
{dropAt === sorted.length && (
|
||||
<div className="relative">
|
||||
<DropLine />
|
||||
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
groups={groups.groups}
|
||||
groupId={groups.of[menu.lane.id] ?? null}
|
||||
onGroup={(target) => {
|
||||
const id = menu.lane.id;
|
||||
setMenu(null);
|
||||
if (target === "new") newGroup([id]);
|
||||
else assignGroup(id, target);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{groupMenu && (
|
||||
<ContextMenu
|
||||
x={groupMenu.x}
|
||||
y={groupMenu.y}
|
||||
onClose={() => setGroupMenu(null)}
|
||||
testId={`workspace-group-menu-${groupMenu.group.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
setRenamingGroup(groupMenu.group.id);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupRename")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={groups.groups[0]?.id === groupMenu.group.id}
|
||||
onSelect={() => {
|
||||
moveGroup(groupMenu.group.id, -1);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupMoveUp")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
|
||||
onSelect={() => {
|
||||
moveGroup(groupMenu.group.id, 1);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupMoveDown")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
testId="workspace-group-add"
|
||||
onSelect={() => {
|
||||
const g = groupMenu.group;
|
||||
setGroupMenu(null);
|
||||
openAdd(undefined, g.id);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupAddWorkspace")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="workspace-group-fold-all"
|
||||
onSelect={() => {
|
||||
setGroupMenu(null);
|
||||
setAllFolded(true);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupFoldAll")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="workspace-group-unfold-all"
|
||||
onSelect={() => {
|
||||
setGroupMenu(null);
|
||||
setAllFolded(false);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupUnfoldAll")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
testId="workspace-group-delete"
|
||||
onSelect={() => {
|
||||
const g = groupMenu.group;
|
||||
setGroupMenu(null);
|
||||
void removeGroup(g);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupDelete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
setAddToGroup(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
if (addToGroup) assignGroup(lane.id, addToGroup);
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What a group header's drag carries: the group's id. */
|
||||
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
|
||||
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
|
||||
|
||||
// needsYou outranks an error, an error a running agent; idle/active say nothing.
|
||||
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
|
||||
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
|
||||
function worstOf(lanes: Lane[]): Lane | null {
|
||||
let best: Lane | null = null;
|
||||
let top = 0;
|
||||
for (const l of lanes) {
|
||||
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
|
||||
if (u > top) [best, top] = [l, u];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** A workspace's owner group: its user id, or "none". */
|
||||
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||
|
||||
/**
|
||||
* A section's header: a user group (foldable, menu, inline rename) or, with
|
||||
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
|
||||
*/
|
||||
function GroupHeader({
|
||||
group,
|
||||
count,
|
||||
worst,
|
||||
holdsSelected,
|
||||
folded,
|
||||
renaming,
|
||||
first,
|
||||
onToggle,
|
||||
onMenu,
|
||||
onRenameEnd,
|
||||
onGroupDrop,
|
||||
}: {
|
||||
group: WorkspaceGroup | null;
|
||||
count: number;
|
||||
/** The most urgent workspace inside, drawn as its dot while folded. */
|
||||
worst: Lane | null;
|
||||
/** The open workspace is in this group (marked while folded). */
|
||||
holdsSelected: boolean;
|
||||
folded: boolean;
|
||||
renaming: boolean;
|
||||
first: boolean;
|
||||
onToggle: () => void;
|
||||
onMenu: (x: number, y: number) => void;
|
||||
/** The typed name, or null when the edit was cancelled. */
|
||||
onRenameEnd: (name: string | null) => void;
|
||||
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
|
||||
onGroupDrop: (draggedId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [draft, setDraft] = useState(group?.name ?? "");
|
||||
const [dropOver, setDropOver] = useState(false);
|
||||
useEffect(() => {
|
||||
if (renaming) setDraft(group?.name ?? "");
|
||||
}, [renaming, group?.name]);
|
||||
const label = group ? group.name : t("tree.ungrouped");
|
||||
return (
|
||||
<div
|
||||
data-group-drop={group?.id ?? ""}
|
||||
data-testid={`workspace-group-${group?.id ?? "none"}`}
|
||||
onContextMenu={(e) => {
|
||||
if (!group) return;
|
||||
e.preventDefault();
|
||||
onMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
// A group (never "Ungrouped") is dragged by its header to reorder.
|
||||
draggable={!!group && !renaming}
|
||||
onDragStart={(e) => {
|
||||
if (!group) return;
|
||||
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!isGroupDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDropOver(false)}
|
||||
onDrop={(e) => {
|
||||
setDropOver(false);
|
||||
if (!isGroupDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
|
||||
if (id) onGroupDrop(id);
|
||||
}}
|
||||
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
|
||||
>
|
||||
{renaming && group ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
maxLength={60}
|
||||
aria-label={t("tree.groupNameLabel")}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => onRenameEnd(draft)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onRenameEnd(draft);
|
||||
else if (e.key === "Escape") onRenameEnd(null);
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={group ? !folded : undefined}
|
||||
disabled={!group}
|
||||
title={group ? t("tree.groupToggle", { name: label }) : undefined}
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
|
||||
>
|
||||
{group ? (
|
||||
folded ? (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
|
||||
{folded && worst && (
|
||||
<span
|
||||
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
|
||||
className="ml-1 flex shrink-0 items-center"
|
||||
>
|
||||
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{group && !renaming && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("tree.groupMenu")}
|
||||
title={t("tree.groupMenu")}
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
onMenu(r.left, r.bottom);
|
||||
}}
|
||||
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||
function DropLine() {
|
||||
return (
|
||||
|
||||
@@ -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,102 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — hidden while it runs", () => {
|
||||
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
|
||||
const PLUGIN: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
};
|
||||
statusMock.mockResolvedValue(PLUGIN);
|
||||
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||
statusMock.mockResolvedValue({
|
||||
...PLUGIN,
|
||||
plugin_update_progress: {
|
||||
step: "plugin_update",
|
||||
index: 2,
|
||||
total: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
|
||||
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const chip = await screen.findByTestId("update-chip");
|
||||
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
|
||||
expect(chip).toHaveTextContent(step);
|
||||
await userEvent.click(chip);
|
||||
expect(
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
|
||||
const running: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
// The new version is already installed: the status no longer offers it.
|
||||
update_available: false,
|
||||
plugin_update_progress: {
|
||||
step: "bootstrap",
|
||||
index: 3,
|
||||
total: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
statusMock.mockResolvedValue(running);
|
||||
render(<UpdateNotifier />);
|
||||
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
|
||||
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const chip = await screen.findByTestId("update-chip");
|
||||
expect(chip).toHaveTextContent(step);
|
||||
await userEvent.click(chip);
|
||||
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||
});
|
||||
|
||||
it("shows no chip when nothing is running", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await screen.findByText(i18n.t("updates:title"));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||
* selection
|
||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||
* search, the row menu's items by kind and tool, and the status dot.
|
||||
* search, the row menu's items by kind and tool, the status dot, and the
|
||||
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
|
||||
* state dot, dragging a header to reorder, fold all).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -91,6 +93,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
|
||||
vi.mock("../lanes/AddLaneModal", () => ({
|
||||
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stub-add-lane"
|
||||
onClick={() => {
|
||||
onAdded(lane(2, "/r/sub"));
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { WorkspaceTree } from "../WorkspaceTree";
|
||||
import { workspaceDot } from "../lanes/WorkspaceRow";
|
||||
@@ -576,6 +592,185 @@ describe("WorkspaceTree", () => {
|
||||
menu = screen.getByTestId("workspace-row-menu-3");
|
||||
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
// No group yet: no headers at all.
|
||||
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||
|
||||
// A new group from the row menu holds that workspace and asks for a name.
|
||||
await user.click(screen.getByTestId("workspace-menu-3"));
|
||||
await user.click(screen.getByTestId("workspace-move-group-new"));
|
||||
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||
await user.clear(name);
|
||||
await user.type(name, "Backend{Enter}");
|
||||
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
|
||||
const gid = stored.groups[0].id as string;
|
||||
expect(stored.of).toEqual({ 3: gid });
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||
);
|
||||
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
|
||||
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
|
||||
|
||||
// Another workspace joins it from its menu.
|
||||
await user.click(screen.getByTestId("workspace-menu-2"));
|
||||
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
|
||||
);
|
||||
|
||||
// Folding hides its rows; the ungrouped one stays.
|
||||
await user.click(
|
||||
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
|
||||
expanded: true,
|
||||
})
|
||||
);
|
||||
expect(rowIds()).toEqual(["workspace-row-1"]);
|
||||
|
||||
// Deleting the group only ungroups: every workspace is still listed.
|
||||
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
|
||||
await user.click(screen.getByTestId("workspace-group-delete"));
|
||||
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
await user.click(screen.getByTestId("workspace-tree-new-group"));
|
||||
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||
await user.clear(name);
|
||||
await user.type(name, "Later{Enter}");
|
||||
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
|
||||
const header = screen.getByTestId(`workspace-group-${gid}`);
|
||||
expect(header).toHaveTextContent("Later0");
|
||||
expect(
|
||||
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
expect(rowIds()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceTree groups", () => {
|
||||
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
|
||||
const seed = (extra: { folded?: boolean } = {}) =>
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.groups",
|
||||
JSON.stringify({
|
||||
groups: [
|
||||
{ id: "ga", name: "Alpha", ...extra },
|
||||
{ id: "gb", name: "Beta" },
|
||||
],
|
||||
of: { 1: "ga", 3: "gb" },
|
||||
})
|
||||
);
|
||||
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||
|
||||
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||
);
|
||||
// The last of Alpha, Alt+↓: the first of Beta.
|
||||
screen.getByTestId("workspace-open-1").focus();
|
||||
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||
);
|
||||
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
|
||||
// The last of Beta, Alt+↓: out into the ungrouped ones.
|
||||
screen.getByTestId("workspace-open-3").focus();
|
||||
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||
expect(stored().of).toEqual({ 1: "gb" });
|
||||
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a folded group's most urgent state and marks the open workspace", async () => {
|
||||
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
|
||||
seed({ folded: true });
|
||||
renderTree("/workspace?lane=1");
|
||||
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
|
||||
const state = screen.getByTestId("workspace-group-state-ga");
|
||||
expect(state).toHaveTextContent("running");
|
||||
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
|
||||
"text-accent"
|
||||
);
|
||||
// An open group shows its rows instead.
|
||||
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
|
||||
});
|
||||
|
||||
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
|
||||
seed();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-gb");
|
||||
const data = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
get types() {
|
||||
return [...data.keys()];
|
||||
},
|
||||
setData: (k: string, v: string) => data.set(k, v),
|
||||
getData: (k: string) => data.get(k) ?? "",
|
||||
dropEffect: "move",
|
||||
effectAllowed: "move",
|
||||
};
|
||||
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||
);
|
||||
data.clear();
|
||||
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
|
||||
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
|
||||
// A header drag never moves a workspace.
|
||||
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
|
||||
});
|
||||
|
||||
it("puts a workspace added from a group's menu into that group", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-gb");
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||
await user.click(screen.getByTestId("workspace-group-add"));
|
||||
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
|
||||
// The header's own Add belongs to no group: what it adds stays where it is.
|
||||
act(() =>
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.groups",
|
||||
JSON.stringify({ ...stored(), of: { 1: "ga" } })
|
||||
)
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-tree-add"));
|
||||
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||
expect(stored().of).toEqual({ 1: "ga" });
|
||||
});
|
||||
|
||||
it("folds and unfolds every group from a header's menu", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-ga");
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
|
||||
await user.click(screen.getByTestId("workspace-group-fold-all"));
|
||||
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||
await user.click(screen.getByTestId("workspace-group-unfold-all"));
|
||||
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceDot", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
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";
|
||||
@@ -374,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
|
||||
@@ -382,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
|
||||
@@ -569,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")}
|
||||
@@ -746,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 ? (
|
||||
@@ -949,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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* 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.
|
||||
* A running clone shows `CloneProgress` and never holds the page: closing the
|
||||
* dialog only hides it behind a small progress chip (click to reopen), the
|
||||
* request keeps going, and its outcome arrives as a toast — a failure is kept
|
||||
* for the next open so a login row is still there to fill in.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +22,8 @@ import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -26,12 +32,15 @@ export function CloneHereDialog({
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
onReopen,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
/** Show the dialog again from the background chip. */
|
||||
onReopen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,10 +51,23 @@ export function CloneHereDialog({
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
/** Where the running clone goes (the open folder may change meanwhile). */
|
||||
const [target, setTarget] = useState("");
|
||||
// Read by the request's own continuation and by the reset below.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const busyRef = useRef(false);
|
||||
/** A clone failed while hidden: the next open shows that, not a blank form. */
|
||||
const keepRef = useRef(false);
|
||||
useDialog(open, panelRef, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || busyRef.current) return;
|
||||
if (keepRef.current) {
|
||||
keepRef.current = false;
|
||||
return;
|
||||
}
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
@@ -54,14 +76,39 @@ export function CloneHereDialog({
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
if (!open) return null;
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
if (!open) {
|
||||
if (!busy || !token) return null;
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="fx-clone-chip"
|
||||
onClick={onReopen}
|
||||
title={into}
|
||||
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("clone.cloning")} {into.split("/").pop()}
|
||||
</span>
|
||||
</span>
|
||||
<CloneProgress token={token} />
|
||||
</button>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
const progressToken = newCloneToken();
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setToken(progressToken);
|
||||
setTarget(into);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
@@ -69,6 +116,7 @@ export function CloneHereDialog({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
progressToken,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
@@ -79,12 +127,16 @@ export function CloneHereDialog({
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
onCloned(result.lane, result.path);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
let message: string;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
@@ -93,18 +145,25 @@ export function CloneHereDialog({
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
message = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
setError(message);
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
if (!openRef.current) {
|
||||
keepRef.current = true;
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={busy ? undefined : onClose}
|
||||
onClick={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()}
|
||||
@@ -119,7 +178,10 @@ export function CloneHereDialog({
|
||||
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">
|
||||
<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>
|
||||
@@ -162,7 +224,10 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
</div>
|
||||
{login && (
|
||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
||||
<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")}
|
||||
@@ -193,6 +258,7 @@ export function CloneHereDialog({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{busy && token && <CloneProgress token={token} />}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
@@ -204,8 +270,8 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||
{t("fx.cancel")}
|
||||
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||
{busy ? t("clone.background") : t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -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>
|
||||
@@ -82,6 +85,7 @@ import {
|
||||
type FsTrashItem,
|
||||
type FsHome,
|
||||
type FsPlace,
|
||||
type GitSource,
|
||||
isHomePlace,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
@@ -92,6 +96,8 @@ 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;
|
||||
@@ -136,6 +142,11 @@ export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
||||
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.
|
||||
@@ -273,7 +284,7 @@ export function FileBrowser({
|
||||
/** A home to open on (`me`, or another user's for an admin). */
|
||||
initialHome?: string | null;
|
||||
session?: string | null;
|
||||
source?: number | null;
|
||||
source?: GitSource | null;
|
||||
initialPath?: string;
|
||||
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
||||
onChanged?: () => void;
|
||||
@@ -318,6 +329,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);
|
||||
@@ -891,6 +912,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"
|
||||
@@ -908,6 +975,7 @@ export function FileBrowser({
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onReopen={() => setCloning(true)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
@@ -919,64 +987,104 @@ export function FileBrowser({
|
||||
{/* ── 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")}>
|
||||
@@ -1775,7 +1883,7 @@ export function FileExplorer({
|
||||
onClose: () => void;
|
||||
laneId: number;
|
||||
session?: string | null;
|
||||
source?: number | null;
|
||||
source?: GitSource | null;
|
||||
initialPath?: string;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
@@ -1987,7 +2095,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}
|
||||
|
||||
@@ -103,9 +103,10 @@ import {
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } 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";
|
||||
@@ -826,7 +827,7 @@ export function LaneChangesPanel({
|
||||
session?: string;
|
||||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||
* wins over `session`, and is keyed on like it. */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||
@@ -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(() => {
|
||||
@@ -2014,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);
|
||||
@@ -3027,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.
|
||||
@@ -3083,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>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import {
|
||||
applyChoices,
|
||||
parseConflicts,
|
||||
@@ -47,7 +47,7 @@ export function LaneConflictView({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
@@ -98,7 +98,7 @@ export function LaneGitBar({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
|
||||
@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
@@ -37,8 +37,8 @@ export function useLaneGitFacts(
|
||||
laneId: number,
|
||||
/** Read that session's own worktree instead (`?session=`). */
|
||||
session?: string,
|
||||
/** Read that extra folder's repo instead (`?source=`). */
|
||||
source?: number
|
||||
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
|
||||
source?: GitSource
|
||||
): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
@@ -329,7 +329,7 @@ export function LaneGitRailCounts({
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
@@ -349,7 +349,7 @@ export function LaneGitHeader({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||
* existing call: Rename (the row's inline field), New workspace here (the
|
||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* managed lane. The server's guards are the real ones; the menu only hides
|
||||
* managed lane — plus Move to group (the sidebar's own groups,
|
||||
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
|
||||
* server's guards are the real ones; the menu only hides
|
||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||
* Escape / outside-click close) is `ContextMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -11,7 +13,9 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { Check } from "lucide-react";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
|
||||
y,
|
||||
onClose,
|
||||
onAction,
|
||||
groups = [],
|
||||
groupId = null,
|
||||
onGroup,
|
||||
}: {
|
||||
lane: Lane;
|
||||
x: number;
|
||||
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
|
||||
onClose: () => void;
|
||||
/** Items the tree itself carries out (rename field, modals). */
|
||||
onAction: (action: WorkspaceMenuAction) => void;
|
||||
/** The sidebar's groups and the one this workspace is in. */
|
||||
groups?: WorkspaceGroup[];
|
||||
groupId?: string | null;
|
||||
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
|
||||
onGroup?: (target: string | null | "new") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
return (
|
||||
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
{onGroup && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t("menu.moveToGroup")}
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<MenuItem
|
||||
key={g.id}
|
||||
testId={`workspace-move-group-${g.id}`}
|
||||
disabled={g.id === groupId}
|
||||
onSelect={() => onGroup(g.id)}
|
||||
>
|
||||
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
|
||||
<span className="truncate">{g.name}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
|
||||
<span className="w-3 shrink-0" />
|
||||
{t("menu.newGroupWith")}
|
||||
</MenuItem>
|
||||
{groupId && (
|
||||
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
|
||||
<span className="w-3 shrink-0" />
|
||||
{t("menu.removeFromGroup")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
|
||||
@@ -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";
|
||||
@@ -28,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneProgress: vi.fn(),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -470,6 +473,59 @@ 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", () => {
|
||||
@@ -498,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows progress and lets the page go on while the clone runs", async () => {
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 1,
|
||||
waiting: 0,
|
||||
limit: 2,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 45,
|
||||
detail: "450/1000",
|
||||
} 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"), "git@host:o/repo.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
const chip = await screen.findByTestId("fx-clone-chip");
|
||||
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
|
||||
await user.click(chip);
|
||||
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
finish({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
|
||||
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"));
|
||||
await user.keyboard("{Escape}");
|
||||
fail(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
const again = await openClone(user);
|
||||
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
|
||||
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
|
||||
"https://git.example/o/r.git"
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
@@ -227,6 +228,13 @@ function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string
|
||||
|
||||
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 kind = artifactKind(path);
|
||||
@@ -320,7 +328,7 @@ export function FilePreview({
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(RENDERED.has(kind) ? "preview" : "code");
|
||||
const [view, setView] = useState<View>(() => defaultView(path));
|
||||
const [full, setFull] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
@@ -332,7 +340,7 @@ export function FilePreview({
|
||||
// Another file opens on its own default tab, at its newest version.
|
||||
useEffect(() => {
|
||||
setPicked(null);
|
||||
setView(RENDERED.has(artifactKind(path)) ? "preview" : "code");
|
||||
setView(defaultView(path));
|
||||
}, [path]);
|
||||
const last = Math.max(versions.length - 1, 0);
|
||||
const at = picked ?? last;
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
claudeMention,
|
||||
copiedFilePaths,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
@@ -452,6 +453,14 @@ export function PromptEditor({
|
||||
});
|
||||
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. */
|
||||
@@ -675,6 +684,7 @@ export function PromptEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
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
|
||||
@@ -863,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]);
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* 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
|
||||
* shows as `[Image #n]` like a native drop — except a PASTED image in a
|
||||
* `claude` pane of a local-mode dashboard opened on its own machine, which is
|
||||
* sent as ^V so `claude` reads the desktop clipboard itself, uploading nothing
|
||||
* (`lib/nativePaste.ts`, a Settings switch); 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
|
||||
@@ -65,20 +68,18 @@ 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,
|
||||
notePaneOutput,
|
||||
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 { readNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
@@ -160,15 +161,6 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
|
||||
const hasText = (e: DragEvent) =>
|
||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||
|
||||
/** The `file://` paths a paste carries as text — a file manager's copy — or
|
||||
* null: its uri-list, else its plain text when every line is one. */
|
||||
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
|
||||
if (!/^file:\/\//.test(text.trim())) return null;
|
||||
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
|
||||
}
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
@@ -252,6 +244,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
|
||||
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||
const ctrlVRef = useRef(false);
|
||||
const { local: localMode } = usePermissions();
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
if (hasText(e)) {
|
||||
@@ -292,6 +285,20 @@ export const TerminalView = memo(function TerminalView({
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// On the dashboard's own machine the browser's clipboard IS the pane's:
|
||||
// `claude` reads a pasted image itself on ^V, as in a native terminal —
|
||||
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
|
||||
const nativeImage =
|
||||
tool === "claude" &&
|
||||
localMode &&
|
||||
isLocalBrowser() &&
|
||||
readNativePaste() &&
|
||||
files.length > 0 &&
|
||||
files.every((f) => f.type.startsWith("image/"));
|
||||
if (nativeImage) {
|
||||
termRef.current?.input("\x16", true);
|
||||
return;
|
||||
}
|
||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
||||
else termRef.current?.input("\x16", true);
|
||||
};
|
||||
|
||||
@@ -590,6 +590,37 @@ describe("TerminalView", () => {
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||
});
|
||||
|
||||
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", () => {
|
||||
dropFileMock.mockReset();
|
||||
inputMock.mockClear();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [img], getData: () => "" },
|
||||
});
|
||||
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
|
||||
localStorage.setItem("agenthub.terminal.nativePaste", "off");
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||
inputMock.mockClear();
|
||||
try {
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [img], getData: () => "" },
|
||||
});
|
||||
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
|
||||
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||
} finally {
|
||||
localStorage.removeItem("agenthub.terminal.nativePaste");
|
||||
}
|
||||
});
|
||||
|
||||
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
||||
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
||||
// pasted like any browser paste: sending ^V for it pasted nothing.
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
||||
* `#terminal-font`, with a preview line), and — local mode only — whether a
|
||||
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
|
||||
* `#native-paste`, this browser only); **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 +40,9 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||||
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
@@ -97,6 +103,9 @@ export function AppearanceSettings() {
|
||||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
const [nativePaste, setNativePaste] = useState(readNativePaste);
|
||||
const { local } = usePermissions();
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
@@ -206,6 +215,18 @@ export function AppearanceSettings() {
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
{local && (
|
||||
<SettingsSwitchRow
|
||||
id="native-paste"
|
||||
label={t("nativePaste.label")}
|
||||
description={t("nativePaste.description")}
|
||||
checked={nativePaste}
|
||||
onChange={(on) => {
|
||||
writeNativePaste(on);
|
||||
setNativePaste(on);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
@@ -224,6 +245,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,26 @@
|
||||
/**
|
||||
* @file ChangesPanelSettings — Settings → Git → Changes panel: whether a
|
||||
* workspace folder that holds several git repositories shows each of them as
|
||||
* its own section in the Changes panel (off by default: only the folder's own
|
||||
* git). Writes the account preference in `lib/innerReposPref.ts`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInnerReposMode, writeInnerReposMode } from "../../lib/innerReposPref";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function ChangesPanelSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const mode = useInnerReposMode();
|
||||
return (
|
||||
<SettingsSection title={t("changesPanel.title")} description={t("changesPanel.description")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("changesPanel.innerRepos")}
|
||||
description={t("changesPanel.innerReposDesc")}
|
||||
checked={mode === "all"}
|
||||
onChange={(on) => writeInnerReposMode(on ? "all" : "own")}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||
* kind of host, the extra keys, the other hosts) — and, for
|
||||
* kind of host, then the other hosts; a named key is a card of its own under
|
||||
* its type's default one, with the same steps, and "Add another key" makes
|
||||
* the next one below) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
@@ -24,17 +26,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -96,17 +88,19 @@ function Code({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||
|
||||
/**
|
||||
* One kind of git host and the key it takes: no key → a Generate button;
|
||||
* a key → step 1 (copy its public half) followed by the host's own steps.
|
||||
* One SSH key and the steps of the host it is for: no key → a Generate
|
||||
* button; a key → step 1 (copy its public half) followed by the host's own
|
||||
* steps. The default key of a type and every named one share it.
|
||||
*/
|
||||
function KeyCard({
|
||||
title,
|
||||
sub,
|
||||
type,
|
||||
label,
|
||||
k,
|
||||
id,
|
||||
writable,
|
||||
busy,
|
||||
onGenerate,
|
||||
onRemove,
|
||||
@@ -114,12 +108,13 @@ function KeyCard({
|
||||
}: {
|
||||
title: string;
|
||||
sub: string;
|
||||
type: "ed25519" | "rsa";
|
||||
/** What names the public key field ("RSA", "ED25519" or the key's name). */
|
||||
label: string;
|
||||
k: Key | undefined;
|
||||
id: GitIdentity;
|
||||
writable: boolean;
|
||||
busy: boolean;
|
||||
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onGenerate: () => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -130,23 +125,19 @@ function KeyCard({
|
||||
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||
</div>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => void onGenerate(type)}
|
||||
>
|
||||
{writable && (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
|
||||
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
{k && id.writable && (
|
||||
{k && writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void onRemove(type)}
|
||||
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
|
||||
onClick={() => void onRemove()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
@@ -165,9 +156,9 @@ function KeyCard({
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
|
||||
value={k.publicKey}
|
||||
rows={type === "rsa" ? 3 : 2}
|
||||
rows={k.type === "rsa" ? 3 : 2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
@@ -182,6 +173,104 @@ function KeyCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
|
||||
* default key's login is the region's plain host; a named key's is saved
|
||||
* under an account name (its own name unless another is typed), tied to it.
|
||||
*/
|
||||
function AwsLoginForm({
|
||||
busy,
|
||||
named,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
/** The named key's name; undefined for the default key. */
|
||||
named?: string;
|
||||
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||
return (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={v.keyId}
|
||||
onChange={(e) => setV({ ...v, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={v.region}
|
||||
onChange={(e) => setV({ ...v, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={v.alias}
|
||||
onChange={(e) => setV({ ...v, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
|
||||
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
|
||||
* offers only this key — the default key would log in as the other account.
|
||||
*/
|
||||
function GitHostForm({
|
||||
busy,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onSave: (host: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [host, setHost] = useState("github.com");
|
||||
return (
|
||||
<form
|
||||
className="mt-1 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(host.trim());
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherHost")}
|
||||
placeholder="github.com"
|
||||
autoComplete="off"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -229,19 +318,42 @@ export function GitIdentitySettings() {
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||
const named = keys.filter((k) => k.name);
|
||||
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
|
||||
const keyLabel = (keyId: string | null | undefined) => {
|
||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||
const makeNamed = async () => {
|
||||
if (!newKey) return;
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||
// "Add another key" in a tab makes the next key of that tab's type at once,
|
||||
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
|
||||
// account name of the user's own goes in its step 3.
|
||||
const makeNamed = (type: "ed25519" | "rsa") => {
|
||||
const prefix = type === "rsa" ? "aws" : "git";
|
||||
let n = 2;
|
||||
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
|
||||
return run(
|
||||
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
};
|
||||
const addKey = (type: "ed25519" | "rsa") => (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.addKey")}
|
||||
</button>
|
||||
);
|
||||
const replaceNamed = async (k: Key) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.replaceConfirm"),
|
||||
confirmLabel: t("gitIdentity.replace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
@@ -304,29 +416,60 @@ export function GitIdentitySettings() {
|
||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||
});
|
||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||
const saveAws = async () => {
|
||||
let user = aws.keyId.trim();
|
||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||
// A login is shown under the key it names; one naming no key (or one gone)
|
||||
// under the default key.
|
||||
const namedIds = new Set(named.map((k) => k.id));
|
||||
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
|
||||
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
|
||||
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
|
||||
let user = v.keyId.trim();
|
||||
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||
if (m?.[1] && m[2]) {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
}
|
||||
const alias = aws.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
// A named key's account goes by the key's name unless another is typed.
|
||||
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||
return run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(aws.key ? { key: aws.key } : {}),
|
||||
...(k?.id ? { key: k.id } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
);
|
||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
||||
};
|
||||
const saveGitHost = (host: string, k: Key) => {
|
||||
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||
return run(
|
||||
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
|
||||
t("gitIdentity.otherSaved", { host: k.name })
|
||||
);
|
||||
};
|
||||
/** The logins of one key, each with its delete button. */
|
||||
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
|
||||
rows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{text(r)}
|
||||
</span>
|
||||
{id?.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||
const saveOther = async () => {
|
||||
let host = other.host.trim();
|
||||
@@ -346,7 +489,7 @@ export function GitIdentitySettings() {
|
||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||
};
|
||||
/** The key picker of a login form: every key, or one. */
|
||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
||||
const keySelect = (value: string, onChange: (v: string) => void) => (
|
||||
<select
|
||||
aria-label={t("gitIdentity.loginKey")}
|
||||
value={value}
|
||||
@@ -355,7 +498,7 @@ export function GitIdentitySettings() {
|
||||
>
|
||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||
{keys
|
||||
.filter((k) => k.id && (!only || k.type === only))
|
||||
.filter((k) => k.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
@@ -396,17 +539,6 @@ export function GitIdentitySettings() {
|
||||
const sshTabs = [
|
||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||
...(named.length > 0 || id.writable
|
||||
? [
|
||||
{
|
||||
id: "more",
|
||||
label: named.length
|
||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
||||
: t("gitIdentity.tabMore"),
|
||||
Icon: KeySquare,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||
: []),
|
||||
@@ -498,228 +630,116 @@ export function GitIdentitySettings() {
|
||||
onSelect={setSshTab}
|
||||
>
|
||||
{activeSshTab === "git" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
<div className="space-y-2">
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
label="ED25519"
|
||||
k={keyOf("ed25519")}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => generate("ed25519")}
|
||||
onRemove={() => removeKey("ed25519")}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
{namedOf("ed25519").map((k) => {
|
||||
const rows = rowsOf(otherRows, k);
|
||||
return (
|
||||
<KeyCard
|
||||
key={k.id}
|
||||
title={k.name!}
|
||||
sub={t("gitIdentity.namedGitSub")}
|
||||
label={k.name!}
|
||||
k={k}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => replaceNamed(k)}
|
||||
onRemove={() => removeNamed(k.id!)}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||
{id.writable && rows.length === 0 && (
|
||||
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
||||
</Code>
|
||||
))}
|
||||
</Step>
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
{id.writable &&
|
||||
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
|
||||
addKey("ed25519")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSshTab === "aws" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
|
||||
const isNamed = i > 0 && !!k;
|
||||
const rows = rowsOf(awsRows, isNamed ? k : undefined);
|
||||
return (
|
||||
<KeyCard
|
||||
key={k?.id ?? "id_rsa"}
|
||||
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
|
||||
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
|
||||
label={isNamed ? k!.name! : "RSA"}
|
||||
k={k}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
|
||||
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{loginRows(
|
||||
rows,
|
||||
(r) =>
|
||||
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||
)}
|
||||
{id.writable && (
|
||||
<AwsLoginForm
|
||||
busy={busy}
|
||||
named={isNamed ? k!.name! : undefined}
|
||||
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={aws.keyId}
|
||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={aws.region}
|
||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{awsRows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
{id.writable && keys.some((k) => k.type === "rsa") && (
|
||||
<>
|
||||
{addKey("rsa")}
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
{activeSshTab === "more" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSshTab === "other" && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,33 @@
|
||||
/**
|
||||
* @file ChangesPanelSettings.test.tsx
|
||||
* @description Settings → Git → Changes panel: the inner-repositories switch
|
||||
* starts off, turning it on stores `all` in the synced account preference,
|
||||
* and turning it off again removes the key (back to the default).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ChangesPanelSettings } from "../ChangesPanelSettings";
|
||||
import { INNER_REPOS_KEY, readInnerReposMode } from "../../../lib/innerReposPref";
|
||||
|
||||
describe("ChangesPanelSettings", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("is off by default and toggles the account preference", () => {
|
||||
render(<ChangesPanelSettings />);
|
||||
const sw = screen.getByRole("switch", {
|
||||
name: /Show the repositories inside the workspace folder/,
|
||||
});
|
||||
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||
expect(readInnerReposMode()).toBe("own");
|
||||
|
||||
fireEvent.click(sw);
|
||||
expect(localStorage.getItem(INNER_REPOS_KEY)).toBe("all");
|
||||
expect(sw).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
fireEvent.click(sw);
|
||||
expect(localStorage.getItem(INNER_REPOS_KEY)).toBeNull();
|
||||
expect(readInnerReposMode()).toBe("own");
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,9 @@
|
||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||
* the sections never mix; a named key
|
||||
* is made beside the default ones, and a second AWS account is saved under
|
||||
* its own name with the one key it uses.
|
||||
* the sections never mix; "Add another key" makes a named key as one more
|
||||
* card in its own tab, a second AWS account is saved from that card under
|
||||
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -216,9 +216,9 @@ describe("GitIdentitySettings", () => {
|
||||
fingerprint: "SHA256:r",
|
||||
};
|
||||
const PROD = {
|
||||
id: "id_rsa_aws-prod",
|
||||
id: "id_rsa_aws-2",
|
||||
type: "rsa" as const,
|
||||
name: "aws-prod",
|
||||
name: "aws-2",
|
||||
publicKey: "ssh-rsa B prod",
|
||||
fingerprint: "SHA256:p",
|
||||
};
|
||||
@@ -230,45 +230,78 @@ describe("GitIdentitySettings", () => {
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [RSA, PROD],
|
||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
// Only an RSA key: the AWS tab opens first.
|
||||
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
|
||||
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||
await tab("More keys");
|
||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
||||
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
|
||||
// One click makes the key — nothing to type before it.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
|
||||
const prod = await screen.findByRole("region", { name: "aws-2" });
|
||||
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
|
||||
// Another key can be added below it.
|
||||
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
// Its own step 3: the account name defaults to the key's name.
|
||||
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA2",
|
||||
alias: "aws-prod",
|
||||
key: "id_rsa_aws-prod",
|
||||
alias: "aws-2",
|
||||
key: "id_rsa_aws-2",
|
||||
});
|
||||
expect(
|
||||
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||
).toBeInTheDocument();
|
||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
||||
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
|
||||
// The default key's card does not list the named key's login.
|
||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||
|
||||
await tab("More keys (1)");
|
||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
|
||||
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
|
||||
});
|
||||
|
||||
it("adds a second GitHub key in its tab and ties it to its host", async () => {
|
||||
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
const WORK = {
|
||||
id: "id_ed25519_git-2",
|
||||
type: "ed25519" as const,
|
||||
name: "git-2",
|
||||
publicKey: "ssh-ed25519 W work",
|
||||
fingerprint: "SHA256:w",
|
||||
};
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||
view({ sshKeys: [ED, WORK], sshUsers: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [ED, WORK],
|
||||
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
|
||||
})
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
|
||||
const work = await screen.findByRole("region", { name: "git-2" });
|
||||
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
|
||||
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: "github.com",
|
||||
user: "git",
|
||||
alias: "git-2",
|
||||
key: "id_ed25519_git-2",
|
||||
});
|
||||
expect(
|
||||
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,56 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`): the
|
||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
||||
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
|
||||
* per user. An admin adds one — for Claude a login MOVED in from a saved
|
||||
* switch-user profile (`GET /api/accounts/profiles`), a pasted
|
||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
||||
* password again; an analyst only reads. The secret only goes IN: the server
|
||||
* never sends it back. An account's Edit (pencil) picks the users who run on
|
||||
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
|
||||
* a Claude login goes into their home at once — no restart.
|
||||
* @description The Users page's account pool card (`GET /api/accounts`), one
|
||||
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
||||
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
||||
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
||||
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
||||
* Oh My Pi has no pool but can share the host's login (the same switch as
|
||||
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
|
||||
* other installed tool says that its users sign in themselves. A tab shows
|
||||
* how many accounts its pool holds, and an empty pool says so instead of
|
||||
* vanishing. An admin adds an account to the open tab's tool — for Claude a
|
||||
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||
* `claude setup-token` token; for Codex a saved Codex profile
|
||||
* (`?tool=codex`), an OpenAI API key or a pasted `auth.json`; for Cursor a
|
||||
* Cursor API key — or removes one, each time with their own password again;
|
||||
* an analyst only reads. The secret only goes IN: the server never sends it
|
||||
* back. An account's Edit (pencil) picks the users who run on it
|
||||
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
|
||||
* Claude login goes into their home at once — no restart. A profile's
|
||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||
* which opens that tool's tab and Add on that profile; the header links back
|
||||
* to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
type AccountPool,
|
||||
type OmpSharingState,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
type CcUsageWindow,
|
||||
} from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||
import { PoolGroups, groupsOf } from "./PoolGroups";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -49,19 +67,81 @@ type Row = {
|
||||
tokens7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||
windows: QuotaWindow[];
|
||||
};
|
||||
|
||||
function rowsOf(pool: AccountPool): Row[] {
|
||||
return [
|
||||
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
|
||||
...pool.codex.map((a) => ({
|
||||
...a,
|
||||
tool: "codex" as const,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
})),
|
||||
];
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
|
||||
/**
|
||||
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||
*/
|
||||
function windowsOf(
|
||||
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
||||
): QuotaWindow[] {
|
||||
const q = a.quota as
|
||||
| {
|
||||
fiveHour?: CcUsageWindow | null;
|
||||
sevenDay?: CcUsageWindow | null;
|
||||
windows?: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const fromClaude = (w: CcUsageWindow | null | undefined) =>
|
||||
w ? { used: w.utilization, resetsAt: w.resetsAt } : null;
|
||||
const fromCodex = (label: string) => {
|
||||
const w = q?.windows?.find((x) => x.label === label);
|
||||
return w ? { used: w.usedPercent, resetsAt: w.resetsAt } : null;
|
||||
};
|
||||
return (["5h", "wk"] as const).map((key) => {
|
||||
const w = (key === "5h" ? fromClaude(q?.fiveHour) : fromClaude(q?.sevenDay)) ?? fromCodex(key);
|
||||
const passed = w?.resetsAt != null && Date.parse(w.resetsAt) <= Date.now();
|
||||
return {
|
||||
key,
|
||||
used: passed ? 0 : (w?.used ?? null),
|
||||
resetsAt: passed ? null : (w?.resetsAt ?? null),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** "2h 10m" / "3d 4h" until `iso`, or null once it has passed. */
|
||||
function untilText(iso: string | null): string | null {
|
||||
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||
if (!(ms > 0)) return null;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const d = Math.floor(m / 1440);
|
||||
const h = Math.floor((m % 1440) / 60);
|
||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||
/** Fixed tabs, always shown; any other installed tool follows them. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
{ id: "omp", label: "Oh My Pi" },
|
||||
];
|
||||
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||
|
||||
const isPoolTool = (id: string): id is PoolTool => (POOL_TOOLS as string[]).includes(id);
|
||||
|
||||
function rowsOf(pool: AccountPool, tool: PoolTool): Row[] {
|
||||
if (tool === "claude") return pool.accounts.map((a) => ({ ...a, tool, windows: windowsOf(a) }));
|
||||
const list = tool === "codex" ? pool.codex : (pool.cursor ?? []);
|
||||
return list.map((a) => ({
|
||||
...a,
|
||||
tool,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
windows: tool === "codex" ? windowsOf(a as AccountPool["codex"][number]) : [],
|
||||
}));
|
||||
}
|
||||
|
||||
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||
@@ -75,9 +155,30 @@ function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
|
||||
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const aiTools = useAiTools();
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
const [omp, setOmp] = useState<OmpSharingState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [params, setParams] = useSearchParams();
|
||||
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
|
||||
const preset = (() => {
|
||||
const tool = params.get("poolAdd");
|
||||
const profile = params.get("poolProfile");
|
||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||
})();
|
||||
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||
isAdmin && preset ? preset : null
|
||||
);
|
||||
const closeAdd = () => {
|
||||
setAdding(null);
|
||||
if (!preset) return;
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("poolAdd");
|
||||
next.delete("poolProfile");
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
const [editing, setEditing] = useState<Row | null>(null);
|
||||
|
||||
@@ -96,19 +197,38 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// Oh My Pi's tab: the host's login shared through omp's own gateway (S3).
|
||||
api.aiTools
|
||||
.ompSharing()
|
||||
.then(setOmp)
|
||||
.catch(() => setOmp(null));
|
||||
}, [load]);
|
||||
|
||||
const rows = pool ? rowsOf(pool) : [];
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
// Every other INSTALLED tool gets a tab too, so "no pool" is said, not implied.
|
||||
const tabs = [
|
||||
...FIXED_TABS,
|
||||
...aiTools.tools
|
||||
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.map((x) => ({ id: x.id, label: x.label })),
|
||||
];
|
||||
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||
const badge = (id: string): string | null => {
|
||||
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
||||
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="account-pool">
|
||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-[14rem]">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||
</div>
|
||||
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
@@ -120,125 +240,78 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("pool.title")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||
>
|
||||
{tabs.map((x) => {
|
||||
const selected = x.id === current.id;
|
||||
const count = badge(x.id);
|
||||
return (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`pool-tab-${x.id}`}
|
||||
aria-selected={selected}
|
||||
aria-controls="pool-panel"
|
||||
onClick={() => setTab(x.id)}
|
||||
className={`-mb-px inline-flex shrink-0 items-center gap-1.5 border-b-2 px-3 pb-2.5 pt-1 text-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent text-fg-primary font-medium"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-4 w-4" />
|
||||
{x.label}
|
||||
{count !== null && (
|
||||
<span
|
||||
className={`rounded-full px-1.5 text-[10px] tabular-nums ${
|
||||
selected ? "bg-accent/15 text-accent" : "bg-surface-3 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && rows.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("pool.account")}</th>
|
||||
<th className={th}>{t("pool.tool")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.quota")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.spend")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
|
||||
<div className="text-[11px] text-fg-secondary">
|
||||
{t("pool.pinned", {
|
||||
users: pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username)
|
||||
.join(", "),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{t(`pool.kind.${r.kind}`)}
|
||||
{r.expired && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.livePanes}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">
|
||||
{fmtCost(r.spend5h)}
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(r)}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoving(r)}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||
{isPoolTool(current.id) ? (
|
||||
<PoolPanel
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
||||
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
||||
onEdit={setEditing}
|
||||
onRemove={setRemoving}
|
||||
/>
|
||||
) : current.id === "omp" ? (
|
||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||
) : (
|
||||
<NoPoolPanel id={current.id} label={current.label} />
|
||||
)}
|
||||
</div>
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
onClose={() => setAdding(false)}
|
||||
tool={adding.tool}
|
||||
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.done.added", { name }));
|
||||
setAdding(false);
|
||||
closeAdd();
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
@@ -278,6 +351,398 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
||||
function PoolPanel({
|
||||
tool,
|
||||
label,
|
||||
pool,
|
||||
isAdmin,
|
||||
installed,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
pool: AccountPool | null;
|
||||
isAdmin: boolean;
|
||||
installed: boolean;
|
||||
/** Add an account; `profile` = share that saved profile into the pool. */
|
||||
onAdd: (profile?: string) => void;
|
||||
onEdit: (r: Row) => void;
|
||||
onRemove: (r: Row) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const rows = pool ? rowsOf(pool, tool) : [];
|
||||
const grouped = (pool?.groups ?? []).length > 0;
|
||||
const addButton = isAdmin && (
|
||||
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
||||
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
||||
{t(`pool.about.${tool}`)}
|
||||
{!installed && (
|
||||
<span className="ml-1 text-status-warning">
|
||||
{t("pool.notInstalled", { tool: label })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 && addButton}
|
||||
</div>
|
||||
{pool && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
||||
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
||||
{addButton}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<AccountCard
|
||||
key={r.name}
|
||||
row={r}
|
||||
pool={pool!}
|
||||
grouped={grouped}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => onEdit(r)}
|
||||
onRemove={() => onRemove(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && SOURCES[tool].includes("profile") && (
|
||||
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
||||
function AccountCard({
|
||||
row: r,
|
||||
pool,
|
||||
grouped,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Row;
|
||||
pool: AccountPool;
|
||||
grouped: boolean;
|
||||
isAdmin: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const metered = METERED.has(r.tool);
|
||||
const users = pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username);
|
||||
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||
}`}
|
||||
data-testid="pool-account"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
</div>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||
{isAdmin && (
|
||||
<div className="-mr-1 -mt-1 flex shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(r.expired || r.refreshError || r.full) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.expired && (
|
||||
<span className="badge py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("pool.liveCount", { count: r.livePanes })}
|
||||
</span>
|
||||
</div>
|
||||
{metered && (
|
||||
<div className="space-y-1.5" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{metered && (
|
||||
<div className="text-[11px] tabular-nums text-fg-muted">
|
||||
<div>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</div>
|
||||
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
||||
</div>
|
||||
)}
|
||||
{grouped && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{inGroups.length === 0 ? (
|
||||
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
||||
) : (
|
||||
inGroups.map((g) => (
|
||||
<span key={g.id} className="badge py-0 text-[10px]">
|
||||
{g.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<UserRound className="h-3 w-3" aria-hidden />
|
||||
{t("pool.pinned", { users: users.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||
* 0% = used up), with when it resets.
|
||||
*/
|
||||
function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
const { t } = useTranslation("users");
|
||||
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<div className="text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${
|
||||
left == null
|
||||
? ""
|
||||
: left === 0
|
||||
? "bg-status-danger"
|
||||
: left <= 10
|
||||
? "bg-status-warning"
|
||||
: "bg-accent-solid"
|
||||
}`}
|
||||
style={{ width: `${left ?? 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-9 shrink-0 text-right tabular-nums text-fg-secondary">
|
||||
{left == null ? "—" : `${left}%`}
|
||||
</span>
|
||||
</div>
|
||||
{resets && (
|
||||
<div
|
||||
className="pl-[3.875rem] text-[10px] text-fg-muted"
|
||||
title={formatDateTime(w.resetsAt!)}
|
||||
>
|
||||
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
||||
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
||||
* leaves the switcher, since a login's refresh token may have one holder.
|
||||
*/
|
||||
function SavedProfiles({
|
||||
tool,
|
||||
pool,
|
||||
onShare,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
||||
pool: AccountPool | null;
|
||||
onShare: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool, pool]);
|
||||
return (
|
||||
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.savedProfiles.title")}
|
||||
</h3>
|
||||
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
</div>
|
||||
{profiles !== null && profiles.length === 0 && (
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
||||
)}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
||||
<div className="truncate text-[11px] text-fg-muted">
|
||||
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
||||
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShare(p.name)}
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||
* same admin switch as Settings → AI tools. Only with per-user accounts (S3).
|
||||
*/
|
||||
function OmpPanel({
|
||||
state,
|
||||
isAdmin,
|
||||
onChange,
|
||||
}: {
|
||||
state: OmpSharingState | null;
|
||||
isAdmin: boolean;
|
||||
onChange: (s: OmpSharingState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const ts = useTranslation("settings").t;
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!state?.available) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-5 py-6">
|
||||
<Share2 className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
<p className="text-sm text-fg-muted leading-relaxed">{t("pool.omp.unavailable")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const error = state.broker.error || state.gateway.error;
|
||||
const status = !state.enabled
|
||||
? t("pool.omp.off")
|
||||
: !state.installed
|
||||
? ts("ompSharing.notInstalled")
|
||||
: state.broker.running && state.gateway.running
|
||||
? ts("ompSharing.running")
|
||||
: error
|
||||
? ts("ompSharing.failed", { error })
|
||||
: ts("ompSharing.starting");
|
||||
return (
|
||||
<div className="px-5 py-3 space-y-2">
|
||||
<p className="text-xs text-fg-secondary leading-relaxed">{t("pool.omp.about")}</p>
|
||||
{isAdmin ? (
|
||||
<SettingsSwitchRow
|
||||
label={ts("ompSharing.switch")}
|
||||
description={ts("ompSharing.hint")}
|
||||
checked={state.enabled}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onChange(await api.aiTools.setOmpSharing(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<p data-testid="omp-pool-status" className="pb-2 text-xs text-fg-muted">
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Any other tool: no pool — say so, and why for the ones we know. */
|
||||
function NoPoolPanel({ id, label }: { id: string; label: string }) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-5 py-6">
|
||||
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-fg-secondary">{t("pool.none.title", { tool: label })}</p>
|
||||
<p className="text-xs text-fg-muted leading-relaxed">
|
||||
{t(`pool.none.reason.${id}`, {
|
||||
defaultValue: t("pool.none.reason.default", { tool: label }),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||
* on another account of the tool moves here when ticked; unticking one sends
|
||||
@@ -412,19 +877,33 @@ function EditAccountDialog({
|
||||
}
|
||||
|
||||
type ClaudeSource = "profile" | "credentials" | "token";
|
||||
/** What each tool's pool account can come from (server/routes/accounts.js). */
|
||||
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
|
||||
claude: ["profile", "credentials", "token"],
|
||||
codex: ["profile", "token"],
|
||||
cursor: ["token"],
|
||||
};
|
||||
|
||||
function AddAccountDialog({
|
||||
tool,
|
||||
label,
|
||||
preset,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
/** The open tab's tool: the account is added to its pool. */
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
/** A saved profile to move in, from its "Move to pool" link. */
|
||||
preset?: { tool: PoolTool; profile: string } | null;
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>("claude");
|
||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
||||
const sources = SOURCES[tool];
|
||||
const [source, setSource] = useState<ClaudeSource>(sources[0] ?? "token");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
const [profile, setProfile] = useState("");
|
||||
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
@@ -432,13 +911,14 @@ function AddAccountDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sources.includes("profile")) return;
|
||||
api.accountPool
|
||||
.profiles()
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
}, [tool, sources]);
|
||||
|
||||
const fromProfile = tool === "claude" && source === "profile";
|
||||
const fromProfile = source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
@@ -449,13 +929,11 @@ function AddAccountDialog({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const body: PoolAccountInput =
|
||||
tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
: source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
: tool === "claude" && source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
try {
|
||||
const r = await api.accountPool.add(body, current);
|
||||
onDone(r.account.name);
|
||||
@@ -472,12 +950,13 @@ function AddAccountDialog({
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
const secretKind = tool === "codex" ? "codex" : source;
|
||||
// Codex's and Cursor's one non-profile source is their own kind of key.
|
||||
const secretKind = tool === "claude" ? source : tool;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.add")}
|
||||
title={t("pool.addFor", { tool: label })}
|
||||
confirmLabel={t("pool.add")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
@@ -487,28 +966,22 @@ function AddAccountDialog({
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
<Field label={t("pool.tool")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => setTool(e.target.value as PoolTool)}
|
||||
aria-label={t("pool.tool")}
|
||||
{sources.length > 1 && (
|
||||
<Field
|
||||
label={t("pool.source")}
|
||||
hint={t(`pool.sourceHint.${tool !== "claude" && source === "token" ? tool : source}`)}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
{tool === "claude" && (
|
||||
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{(["profile", "credentials", "token"] as const).map((s) => (
|
||||
{sources.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`pool.sources.${s}`)}
|
||||
{tool !== "claude" && s === "token"
|
||||
? t(`pool.secret.${tool}`)
|
||||
: t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -553,7 +1026,7 @@ function AddAccountDialog({
|
||||
</Field>
|
||||
{!fromProfile && (
|
||||
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
||||
{secretKind === "token" ? (
|
||||
{secretKind === "token" || secretKind === "cursor" ? (
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full font-mono text-sm"
|
||||
|
||||
@@ -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,7 +1,9 @@
|
||||
/**
|
||||
* @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
|
||||
@@ -13,6 +15,7 @@
|
||||
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(() => ({
|
||||
@@ -21,15 +24,31 @@ const accountPool = vi.hoisted(() => ({
|
||||
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, users } };
|
||||
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: [
|
||||
{
|
||||
@@ -45,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: [
|
||||
{
|
||||
@@ -66,8 +120,30 @@ 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: [
|
||||
@@ -90,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());
|
||||
@@ -134,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");
|
||||
@@ -159,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" }));
|
||||
@@ -184,7 +387,7 @@ describe("AccountPoolCard", () => {
|
||||
removed: [{ userId: 8, username: "leo" }],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
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" }));
|
||||
@@ -206,4 +409,40 @@ describe("AccountPoolCard", () => {
|
||||
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}
|
||||
|
||||
@@ -117,6 +117,20 @@
|
||||
"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",
|
||||
@@ -126,12 +140,7 @@
|
||||
"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.",
|
||||
"reinstall": "Reinstall",
|
||||
"reinstallHint": "Write this version over your installed copy",
|
||||
"reinstallTitle": "Reinstall “{{title}}”?",
|
||||
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallHint": "Remove it from your account",
|
||||
"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.",
|
||||
@@ -145,7 +154,7 @@
|
||||
"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 Reinstall it.",
|
||||
"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",
|
||||
@@ -174,5 +183,19 @@
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}"
|
||||
"detail.added": "Added {{date}}",
|
||||
"tab.unowned": "Unowned",
|
||||
"tab.suggested": "Is this yours?",
|
||||
"auto": "Auto",
|
||||
"autoHint": "Added when its session stopped: a document the session created or changed",
|
||||
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
|
||||
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
|
||||
"unowned.mine": "It's mine",
|
||||
"unowned.takeIt": "Take it",
|
||||
"unowned.notMine": "Not mine",
|
||||
"unowned.assign": "Assign to…",
|
||||
"unowned.claimed": "Added to your library",
|
||||
"unowned.declined": "Removed from your list",
|
||||
"unowned.assigned": "Owner assigned",
|
||||
"unowned.empty": "Nothing without an owner."
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||
"clone.nameLabel": "Folder name",
|
||||
"clone.cloning": "Cloning…",
|
||||
"clone.background": "Run in background",
|
||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||
"clone.username": "Username",
|
||||
@@ -650,9 +651,29 @@
|
||||
"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.",
|
||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
||||
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
|
||||
"tmuxUpgrade": "Upgrade tmux: {{advice}}",
|
||||
"changes.rescanRepos": "Scan for repositories again",
|
||||
"tree.newGroup": "New group",
|
||||
"tree.groupDefaultName": "New group",
|
||||
"tree.ungrouped": "Ungrouped",
|
||||
"tree.groupToggle": "Show or hide the {{name}} group",
|
||||
"tree.groupMenu": "Group options",
|
||||
"tree.groupRename": "Rename group",
|
||||
"tree.groupDelete": "Delete group",
|
||||
"tree.groupMoveUp": "Move group up",
|
||||
"tree.groupMoveDown": "Move group down",
|
||||
"tree.groupDeleteConfirm": "Delete the group \"{{name}}\"? Its workspaces stay, ungrouped.",
|
||||
"tree.groupNameLabel": "Group name",
|
||||
"menu.moveToGroup": "Move to group",
|
||||
"menu.removeFromGroup": "Remove from group",
|
||||
"menu.newGroupWith": "New group…",
|
||||
"tree.groupAddWorkspace": "New workspace in this group…",
|
||||
"tree.groupFoldAll": "Fold all groups",
|
||||
"tree.groupUnfoldAll": "Unfold all groups"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}}",
|
||||
@@ -944,16 +908,14 @@
|
||||
"alias": "Account name",
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "More SSH keys",
|
||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
||||
"newKey": "New key",
|
||||
"keyName": "Key name",
|
||||
"keyNamePlaceholder": "e.g. aws-prod",
|
||||
"keyType": "Key type",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "More keys",
|
||||
"tabOther": "Other hosts"
|
||||
"tabOther": "Other hosts",
|
||||
"addKey": "Add another key",
|
||||
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
||||
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||
"namedAwsSub": "RSA · for one more AWS account.",
|
||||
"removeKeyNamed": "Delete key {{name}}"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -1113,6 +1075,12 @@
|
||||
"noMatch": "No setting matches “{{query}}”.",
|
||||
"results": "Search results"
|
||||
},
|
||||
"changesPanel": {
|
||||
"title": "Changes panel",
|
||||
"description": "For a workspace folder that holds several git repositories (each with its own .git). Saved to your account.",
|
||||
"innerRepos": "Show the repositories inside the workspace folder",
|
||||
"innerReposDesc": "Each repository found inside the folder (up to two levels down) gets its own section in the Changes panel. Off: only the folder's own git is shown."
|
||||
},
|
||||
"worktrees": {
|
||||
"title": "Worktree suggestions",
|
||||
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
|
||||
@@ -1140,6 +1108,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.",
|
||||
@@ -1157,6 +1140,10 @@
|
||||
"18": "Larger"
|
||||
}
|
||||
},
|
||||
"nativePaste": {
|
||||
"label": "Paste images natively",
|
||||
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
||||
@@ -1339,7 +1326,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",
|
||||
@@ -1367,5 +1358,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,11 @@
|
||||
"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.",
|
||||
"chipTitle": "Updating to {{version}}…",
|
||||
"chipHint": "The update runs in the background — click to show it",
|
||||
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||
"pluginStep.plugin_update": "Step {{index}}/{{total}}: downloading the new version",
|
||||
"pluginStep.bootstrap": "Step {{index}}/{{total}}: installing it and restarting the dashboard"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"role": "Role",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"today": "Today",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"storage": "Storage",
|
||||
@@ -30,7 +31,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 +69,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 +85,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 +113,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 +128,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 +145,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}}",
|
||||
@@ -167,35 +166,40 @@
|
||||
"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.",
|
||||
"ENOUSER": "That user no longer exists."
|
||||
"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",
|
||||
"pinned": "Used by: {{users}}",
|
||||
@@ -210,7 +214,71 @@
|
||||
"users": "Users on this account",
|
||||
"noUsers": "No users yet.",
|
||||
"onAccount": "now on {{account}}",
|
||||
"editDone": "{{name}} saved"
|
||||
"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",
|
||||
@@ -253,5 +321,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,20 @@
|
||||
"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",
|
||||
@@ -126,12 +140,7 @@
|
||||
"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.",
|
||||
"reinstall": "Cài lại",
|
||||
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
|
||||
"reinstallTitle": "Cài lại “{{title}}”?",
|
||||
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||
"uninstall": "Gỡ cài đặt",
|
||||
"uninstallHint": "Gỡ khỏi tài khoản của bạn",
|
||||
"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.",
|
||||
@@ -145,7 +154,7 @@
|
||||
"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ấm Cài lại.",
|
||||
"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",
|
||||
@@ -174,5 +183,19 @@
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}"
|
||||
"detail.added": "Thêm lúc {{date}}",
|
||||
"tab.unowned": "Vô chủ",
|
||||
"tab.suggested": "Có phải của bạn?",
|
||||
"auto": "Tự động",
|
||||
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
|
||||
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
|
||||
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
|
||||
"unowned.mine": "Của tôi",
|
||||
"unowned.takeIt": "Nhận về tôi",
|
||||
"unowned.notMine": "Không phải của tôi",
|
||||
"unowned.assign": "Gán cho…",
|
||||
"unowned.claimed": "Đã nhận về thư viện của bạn",
|
||||
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
|
||||
"unowned.assigned": "Đã gán chủ",
|
||||
"unowned.empty": "Không có mục nào chưa có chủ."
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||
"clone.nameLabel": "Tên thư mục",
|
||||
"clone.cloning": "Đang clone…",
|
||||
"clone.background": "Chạy nền",
|
||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||
"clone.username": "Username",
|
||||
@@ -650,9 +651,29 @@
|
||||
"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.",
|
||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
||||
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
|
||||
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}",
|
||||
"changes.rescanRepos": "Quét lại các repository",
|
||||
"tree.newGroup": "Nhóm mới",
|
||||
"tree.groupDefaultName": "Nhóm mới",
|
||||
"tree.ungrouped": "Chưa phân nhóm",
|
||||
"tree.groupToggle": "Hiện hoặc ẩn nhóm {{name}}",
|
||||
"tree.groupMenu": "Tuỳ chọn nhóm",
|
||||
"tree.groupRename": "Đổi tên nhóm",
|
||||
"tree.groupDelete": "Xoá nhóm",
|
||||
"tree.groupMoveUp": "Đưa nhóm lên",
|
||||
"tree.groupMoveDown": "Đưa nhóm xuống",
|
||||
"tree.groupDeleteConfirm": "Xoá nhóm \"{{name}}\"? Các workspace trong nhóm vẫn giữ nguyên, chỉ không còn thuộc nhóm.",
|
||||
"tree.groupNameLabel": "Tên nhóm",
|
||||
"menu.moveToGroup": "Chuyển vào nhóm",
|
||||
"menu.removeFromGroup": "Bỏ khỏi nhóm",
|
||||
"menu.newGroupWith": "Nhóm mới…",
|
||||
"tree.groupAddWorkspace": "Workspace mới trong nhóm này…",
|
||||
"tree.groupFoldAll": "Thu gọn mọi nhóm",
|
||||
"tree.groupUnfoldAll": "Mở rộng mọi nhóm"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}}",
|
||||
@@ -944,16 +896,14 @@
|
||||
"alias": "Tên tài khoản",
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "Thêm SSH key",
|
||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
||||
"newKey": "Key mới",
|
||||
"keyName": "Tên key",
|
||||
"keyNamePlaceholder": "vd aws-prod",
|
||||
"keyType": "Loại key",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "Key khác",
|
||||
"tabOther": "Host khác"
|
||||
"tabOther": "Host khác",
|
||||
"addKey": "Thêm key khác",
|
||||
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
||||
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||
"removeKeyNamed": "Xoá key {{name}}"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -1113,6 +1063,12 @@
|
||||
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
|
||||
"results": "Kết quả tìm kiếm"
|
||||
},
|
||||
"changesPanel": {
|
||||
"title": "Panel Changes",
|
||||
"description": "Dành cho thư mục workspace chứa nhiều repo git (mỗi repo có .git riêng). Lưu theo tài khoản của bạn.",
|
||||
"innerRepos": "Hiện các repo bên trong thư mục workspace",
|
||||
"innerReposDesc": "Mỗi repo tìm thấy trong thư mục (sâu tối đa hai cấp) có một mục riêng trong panel Changes. Tắt: chỉ hiện git của chính thư mục."
|
||||
},
|
||||
"worktrees": {
|
||||
"title": "Gợi ý worktree",
|
||||
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
|
||||
@@ -1140,6 +1096,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.",
|
||||
@@ -1157,6 +1128,10 @@
|
||||
"18": "Rất lớn"
|
||||
}
|
||||
},
|
||||
"nativePaste": {
|
||||
"label": "Dán ảnh kiểu native",
|
||||
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
||||
@@ -1339,7 +1314,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ừ",
|
||||
@@ -1367,5 +1346,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,11 @@
|
||||
"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.",
|
||||
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
||||
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||
"pluginStep.plugin_update": "Bước {{index}}/{{total}}: tải phiên bản mới",
|
||||
"pluginStep.bootstrap": "Bước {{index}}/{{total}}: cài đặt và khởi động lại bảng điều khiển"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"role": "Vai trò",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Phiên",
|
||||
"today": "Hôm nay",
|
||||
"last7d": "7 ngày",
|
||||
"last30d": "30 ngày",
|
||||
"storage": "Dung lượng",
|
||||
@@ -30,7 +31,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 +69,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 +85,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 +113,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 +128,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 +145,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}}",
|
||||
@@ -167,35 +166,40 @@
|
||||
"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.",
|
||||
"ENOUSER": "User đó không còn tồn tạ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",
|
||||
"pinned": "Đang dùng: {{users}}",
|
||||
@@ -210,7 +214,71 @@
|
||||
"users": "User dùng tài khoản này",
|
||||
"noUsers": "Chưa có user nào.",
|
||||
"onAccount": "đang ở {{account}}",
|
||||
"editDone": "Đã lưu {{name}}"
|
||||
"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",
|
||||
@@ -253,5 +321,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudePastes } from "../promptAttach";
|
||||
import { claudePastes, copiedFilePaths, localCandidates } from "../promptAttach";
|
||||
|
||||
describe("claudePastes", () => {
|
||||
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
|
||||
@@ -18,3 +18,46 @@ describe("claudePastes", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A clipboard as the browser hands it to a paste handler. */
|
||||
const clip = (data: Record<string, string>) =>
|
||||
({ getData: (t: string) => data[t] ?? "" }) as unknown as DataTransfer;
|
||||
const file = (name: string) => new File(["x"], name);
|
||||
|
||||
describe("localCandidates (a file manager's copy, pasted)", () => {
|
||||
it("takes the plain-text paths when their names match the pasted files", () => {
|
||||
expect(window.location.hostname).toBe("localhost");
|
||||
const dt = clip({ "text/plain": "/home/u/Videos/big.mp4\n/home/u/b c.pdf\n" });
|
||||
expect(localCandidates(dt, [file("b c.pdf"), file("big.mp4")])).toEqual([
|
||||
"/home/u/Videos/big.mp4",
|
||||
"/home/u/b c.pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes file:// lines too, decoded", () => {
|
||||
const dt = clip({ "text/plain": "file:///home/u/a%20b.zip" });
|
||||
expect(localCandidates(dt, [file("a b.zip")])).toEqual(["/home/u/a b.zip"]);
|
||||
});
|
||||
|
||||
it("refuses text that does not name exactly the pasted files", () => {
|
||||
expect(localCandidates(clip({ "text/plain": "/etc/passwd" }), [file("big.mp4")])).toBeNull();
|
||||
expect(localCandidates(clip({ "text/plain": "hello" }), [file("hello")])).toBeNull();
|
||||
expect(
|
||||
localCandidates(clip({ "text/plain": "/a/x.txt" }), [file("x.txt"), file("y.txt")])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers a uri-list (a drag) as before", () => {
|
||||
const dt = clip({ "text/uri-list": "file:///tmp/z.bin", "text/plain": "/other/z.bin" });
|
||||
expect(localCandidates(dt, [file("z.bin")])).toEqual(["/tmp/z.bin"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copiedFilePaths (no file beside the text)", () => {
|
||||
it("takes file:// text, never bare absolute paths, which stay text to paste", () => {
|
||||
expect(copiedFilePaths(clip({ "text/plain": "file:///home/u/a.txt" }))).toEqual([
|
||||
"/home/u/a.txt",
|
||||
]);
|
||||
expect(copiedFilePaths(clip({ "text/plain": "/home/u/a.txt" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file workspaceGroups.test.ts
|
||||
* @description Tests for the sidebar's workspace groups store: create, rename,
|
||||
* move (by step or onto another group) and delete (which only ungroups), fold all, assignment, pruning of removed
|
||||
* workspaces, a damaged stored value, and the arrangement the sidebar draws —
|
||||
* groups in order, each keeping the drag order, ungrouped last.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
WORKSPACE_GROUPS_KEY,
|
||||
arrangeByGroup,
|
||||
assignGroup,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
groupOf,
|
||||
moveGroup,
|
||||
moveGroupBefore,
|
||||
pruneWorkspaceGroups,
|
||||
readWorkspaceGroups,
|
||||
renameGroup,
|
||||
setAllFolded,
|
||||
setGroupFolded,
|
||||
} from "../workspaceGroups";
|
||||
|
||||
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
|
||||
|
||||
describe("workspaceGroups", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("starts empty and survives a damaged value", () => {
|
||||
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
|
||||
localStorage.setItem(WORKSPACE_GROUPS_KEY, "{nope");
|
||||
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
|
||||
});
|
||||
|
||||
it("creates a group holding workspaces, renames, folds and deletes it", () => {
|
||||
const id = createGroup(" Frontend ", [1, 2]);
|
||||
let v = readWorkspaceGroups();
|
||||
expect(v.groups).toEqual([{ id, name: "Frontend" }]);
|
||||
expect(groupOf(v, 1)?.id).toBe(id);
|
||||
renameGroup(id, " ");
|
||||
expect(readWorkspaceGroups().groups[0]!.name).toBe("Frontend");
|
||||
renameGroup(id, "Web");
|
||||
setGroupFolded(id, true);
|
||||
v = readWorkspaceGroups();
|
||||
expect(v.groups[0]).toMatchObject({ name: "Web", folded: true });
|
||||
deleteGroup(id);
|
||||
v = readWorkspaceGroups();
|
||||
expect(v.groups).toEqual([]);
|
||||
expect(v.of).toEqual({});
|
||||
});
|
||||
|
||||
it("moves a workspace between groups and out of them", () => {
|
||||
const a = createGroup("A");
|
||||
const b = createGroup("B");
|
||||
assignGroup(5, a);
|
||||
assignGroup(5, b);
|
||||
expect(readWorkspaceGroups().of["5"]).toBe(b);
|
||||
assignGroup(5, "missing");
|
||||
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
|
||||
assignGroup(5, a);
|
||||
assignGroup(5, null);
|
||||
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reorders groups, clamped at both ends", () => {
|
||||
const a = createGroup("A");
|
||||
const b = createGroup("B");
|
||||
moveGroup(a, 1);
|
||||
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
|
||||
moveGroup(a, 5);
|
||||
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
|
||||
});
|
||||
|
||||
it("forgets removed workspaces and ignores ids naming no group", () => {
|
||||
const a = createGroup("A", [1, 2, 3]);
|
||||
pruneWorkspaceGroups([1, 3]);
|
||||
expect(readWorkspaceGroups().of).toEqual({ 1: a, 3: a });
|
||||
localStorage.setItem(
|
||||
WORKSPACE_GROUPS_KEY,
|
||||
JSON.stringify({ groups: [{ id: a, name: "A" }], of: { 1: a, 2: "gone" } })
|
||||
);
|
||||
expect(readWorkspaceGroups().of).toEqual({ 1: a });
|
||||
});
|
||||
|
||||
it("arranges groups in order, keeping the drag order, ungrouped last", () => {
|
||||
const a = createGroup("A", [4, 2]);
|
||||
const b = createGroup("B", [3]);
|
||||
moveGroup(b, -1);
|
||||
const v = readWorkspaceGroups();
|
||||
expect(arrangeByGroup(lanes(1, 2, 3, 4, 5), v).map((l) => l.id)).toEqual([3, 2, 4, 1, 5]);
|
||||
expect(groupOf(v, 2)?.id).toBe(a);
|
||||
});
|
||||
|
||||
it("moves a group before another, or last, and folds every group at once", () => {
|
||||
const a = createGroup("A");
|
||||
const b = createGroup("B");
|
||||
const c = createGroup("C");
|
||||
const ids = () => readWorkspaceGroups().groups.map((g) => g.id);
|
||||
moveGroupBefore(c, a);
|
||||
expect(ids()).toEqual([c, a, b]);
|
||||
moveGroupBefore(c, null);
|
||||
expect(ids()).toEqual([a, b, c]);
|
||||
moveGroupBefore(a, a);
|
||||
moveGroupBefore("nope", b);
|
||||
expect(ids()).toEqual([a, b, c]);
|
||||
setAllFolded(true);
|
||||
expect(readWorkspaceGroups().groups.every((g) => g.folded)).toBe(true);
|
||||
setAllFolded(false);
|
||||
expect(readWorkspaceGroups().groups.some((g) => g.folded)).toBe(false);
|
||||
});
|
||||
});
|
||||
+180
-13
@@ -538,6 +538,8 @@ export type ServerUserUsage = AuthUser & {
|
||||
disabled: boolean;
|
||||
workspaces: number;
|
||||
sessions: number;
|
||||
/** Since the viewer's local midnight (absent from an older server). */
|
||||
today?: { cost: number; tokens: number };
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** Active login sessions (cookies not expired). */
|
||||
@@ -660,6 +662,13 @@ export type PoolAccount = {
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
/** The last usage reading of the account's own token, or null before one. */
|
||||
quota?: {
|
||||
fiveHour: CcUsageWindow | null;
|
||||
sevenDay: CcUsageWindow | null;
|
||||
checkedAt: string;
|
||||
error: string | null;
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -673,6 +682,11 @@ export type CodexPoolAccount = {
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
/** The newest `rate_limits` its sessions' rollouts carry (`5h`, `wk`, …), or null. */
|
||||
quota?: {
|
||||
windows: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||
asOf: string;
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -683,14 +697,37 @@ export type AccountPin = {
|
||||
account: string;
|
||||
pinnedAt?: string;
|
||||
};
|
||||
/** One Cursor pool account — a Cursor API key, never the key itself; no quota. */
|
||||
export type CursorPoolAccount = Omit<CodexPoolAccount, "kind"> & { kind: "apikey" };
|
||||
export type AccountPool = {
|
||||
accounts: PoolAccount[];
|
||||
codex: CodexPoolAccount[];
|
||||
/** Absent from a server older than the Cursor pool. */
|
||||
cursor?: CursorPoolAccount[];
|
||||
/** Per tool; absent from an older server, empty in local mode. */
|
||||
pins?: { claude: AccountPin[]; codex: AccountPin[] };
|
||||
pins?: Partial<Record<PoolTool, AccountPin[]>>;
|
||||
/** Server mode's pool groups; absent from an older server, empty in local mode. */
|
||||
groups?: PoolGroup[];
|
||||
blockPercent: number;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex";
|
||||
/**
|
||||
* A pool group: its members (one group per user) run only on its accounts;
|
||||
* a user in no group runs on the accounts no group lists (the shared pool).
|
||||
*/
|
||||
export type PoolGroup = {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt?: string;
|
||||
users: { userId: number; username: string }[];
|
||||
accounts: Partial<Record<PoolTool, string[]>>;
|
||||
};
|
||||
/** What `PATCH /api/accounts/groups/:id` takes — every part optional. */
|
||||
export type PoolGroupPatch = {
|
||||
name?: string;
|
||||
userIds?: number[];
|
||||
accounts?: Partial<Record<PoolTool, string[]>>;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex" | "cursor";
|
||||
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||
export type PoolProfile = {
|
||||
name: string;
|
||||
@@ -699,6 +736,29 @@ export type PoolProfile = {
|
||||
subscriptionType: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
|
||||
export type ToolProfileIdentity = {
|
||||
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
|
||||
kind: string;
|
||||
email: string | null;
|
||||
/** codex: the ChatGPT plan or `…1234` of a key; omp: its providers. */
|
||||
detail: string | null;
|
||||
};
|
||||
export type ToolProfile = ToolProfileIdentity & {
|
||||
name: string;
|
||||
label: string;
|
||||
savedAt: number | null;
|
||||
active: boolean;
|
||||
};
|
||||
export type ToolProfilesTool = {
|
||||
tool: string;
|
||||
supported: boolean;
|
||||
/** Why a tool cannot be switched (`supported: false`). */
|
||||
reason?: string;
|
||||
items: ToolProfile[];
|
||||
active: ToolProfileIdentity | null;
|
||||
error?: { code?: string; message: string } | null;
|
||||
};
|
||||
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
|
||||
export type PoolAccountInput = {
|
||||
tool: PoolTool;
|
||||
@@ -733,6 +793,8 @@ export type AuthUser = {
|
||||
/** When the account's avatar was last set (null = none) — versions its URL. */
|
||||
avatarAt?: string | null;
|
||||
createdAt?: string;
|
||||
/** An admin gave this password: the user must choose their own before anything else. */
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
@@ -833,11 +895,17 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
/**
|
||||
* What a workspace git call works on: the workspace (its id) or, with
|
||||
* `session`, that session's own worktree (`claude --worktree`), or, with
|
||||
* `source`, one of the workspace's extra folders — the server resolves the
|
||||
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
|
||||
* never from a path.
|
||||
* `source`, one of the workspace's extra folders, or with `repo`, a repo
|
||||
* inside the workspace folder — the server resolves the tree from the session
|
||||
* or `lane_sources` row (`?session=` / `?source=`), or accepts `?repo=` only
|
||||
* when its own walk of the folder found that repo; never from a path.
|
||||
*/
|
||||
export type GitTarget = number | { lane: number; session?: string; source?: number };
|
||||
export type GitTarget = number | { lane: number; session?: string; source?: number; repo?: string };
|
||||
|
||||
/** Which extra repo a Changes section reads: an extra folder's `lane_sources`
|
||||
* id (number), or a repo inside the workspace folder, by its path relative
|
||||
* to it (string, from `GET /lanes/:id/repos`). */
|
||||
export type GitSource = number | string;
|
||||
|
||||
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
||||
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
@@ -845,6 +913,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
const lane = typeof target === "number" ? target : target.lane;
|
||||
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
||||
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
||||
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
|
||||
const qs = params.toString();
|
||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
@@ -943,13 +1012,14 @@ export interface FsTrashItem {
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
|
||||
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
||||
/** The target for `laneId`: an extra folder's or an inner repo, else `session`'s worktree, else the workspace. */
|
||||
export function gitTarget(
|
||||
laneId: number,
|
||||
session?: string | null,
|
||||
source?: number | null
|
||||
source?: GitSource | null
|
||||
): GitTarget {
|
||||
if (source) return { lane: laneId, source };
|
||||
if (typeof source === "string" && source) return { lane: laneId, repo: source };
|
||||
if (typeof source === "number" && source) return { lane: laneId, source };
|
||||
return session ? { lane: laneId, session } : laneId;
|
||||
}
|
||||
|
||||
@@ -1007,6 +1077,9 @@ export const api = {
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
/** POST /api/me/avatar/url — the server downloads the picture from an https link. */
|
||||
setAvatarFromUrl: (url: string) =>
|
||||
request<MyAccount>("/me/avatar/url", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
},
|
||||
@@ -1020,7 +1093,8 @@ export const api = {
|
||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
||||
list: () =>
|
||||
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||
* account changes, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
@@ -1091,6 +1165,33 @@ export const api = {
|
||||
body: JSON.stringify({ laneId, currentPassword }),
|
||||
}),
|
||||
},
|
||||
/** Login profiles of Codex, Oh My Pi… (Claude's: `ccConfig.profiles`). */
|
||||
toolProfiles: {
|
||||
/** GET /api/tool-profiles — every covered tool, switchable or not. */
|
||||
list: () => request<{ tools: ToolProfilesTool[] }>("/tool-profiles"),
|
||||
/** POST /api/tool-profiles/:tool — save the login the tool holds now. */
|
||||
save: (tool: string) =>
|
||||
request<{ ok: true; name: string; label: string }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}`,
|
||||
{ method: "POST", body: JSON.stringify({}) }
|
||||
),
|
||||
rename: (tool: string, name: string, label: string) =>
|
||||
request<{ ok: true; name: string; label: string }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ name, label }) }
|
||||
),
|
||||
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
||||
activate: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
remove: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string }>(`/tool-profiles/${encodeURIComponent(tool)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
},
|
||||
/** The Claude/Codex account pool every member's new panes run on
|
||||
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
|
||||
accountPool: {
|
||||
@@ -1098,7 +1199,10 @@ export const api = {
|
||||
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
|
||||
/** POST /api/accounts — admin, with their OWN password again. */
|
||||
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
|
||||
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
|
||||
profiles: (tool: PoolTool = "claude") =>
|
||||
request<{ profiles: PoolProfile[] }>(
|
||||
tool === "codex" ? "/accounts/profiles?tool=codex" : "/accounts/profiles"
|
||||
),
|
||||
add: (body: PoolAccountInput, currentPassword: string) =>
|
||||
request<{ account: { name: string } }>("/accounts", {
|
||||
method: "POST",
|
||||
@@ -1124,6 +1228,24 @@ export const api = {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ tool, currentPassword }),
|
||||
}),
|
||||
/** POST /api/accounts/groups — admin: a new, empty pool group. */
|
||||
createGroup: (name: string, currentPassword: string) =>
|
||||
request<{ group: { id: number; name: string } }>("/accounts/groups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, currentPassword }),
|
||||
}),
|
||||
/** PATCH /api/accounts/groups/:id — admin: each part given is set exactly. */
|
||||
updateGroup: (id: number, body: PoolGroupPatch, currentPassword: string) =>
|
||||
request<{ group: PoolGroup }>(`/accounts/groups/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** DELETE /api/accounts/groups/:id — its members go back to the shared pool. */
|
||||
deleteGroup: (id: number, currentPassword: string) =>
|
||||
request<{ ok: true }>(`/accounts/groups/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
}),
|
||||
},
|
||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||
/** Self-update status: whether this install is a git clone and, if so,
|
||||
@@ -2905,7 +3027,9 @@ export const api = {
|
||||
},
|
||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||
artifacts: {
|
||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
||||
list: (
|
||||
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
if (params.kind) qs.set("kind", params.kind);
|
||||
@@ -2981,6 +3105,18 @@ export const api = {
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||
},
|
||||
/** An unowned row: its suggested user (or an admin) takes it. */
|
||||
claim: (id: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
|
||||
/** An unowned row offered to the caller is not theirs. */
|
||||
decline: (id: number) =>
|
||||
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
|
||||
/** Admin: give an unowned row to a user. */
|
||||
assign: (id: number, userId: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId }),
|
||||
}),
|
||||
update: (
|
||||
id: number,
|
||||
body: {
|
||||
@@ -3051,6 +3187,14 @@ export const api = {
|
||||
aiTools: {
|
||||
/** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */
|
||||
list: () => request<AiToolsView>("/ai-tools"),
|
||||
/** GET /api/ai-tools/omp-sharing - the host's Oh My Pi login shared with members (S3). */
|
||||
ompSharing: () => request<OmpSharingState>("/ai-tools/omp-sharing"),
|
||||
/** PUT /api/ai-tools/omp-sharing - an admin's switch. */
|
||||
setOmpSharing: (enabled: boolean) =>
|
||||
request<OmpSharingState>("/ai-tools/omp-sharing", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
||||
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
||||
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
||||
@@ -4038,6 +4182,13 @@ export const api = {
|
||||
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
||||
* folders and context files, and whether its tool can take them. */
|
||||
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
|
||||
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
|
||||
* (a parent folder of several repos), each a Changes section read with
|
||||
* `?repo=<path>`; `root` = the folder itself is in a repo. */
|
||||
repos: (id: number, refresh = false) =>
|
||||
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
|
||||
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}`
|
||||
),
|
||||
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
|
||||
setInstructions: (id: number, instructions: string | null) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
|
||||
@@ -4263,7 +4414,7 @@ export interface Artifact {
|
||||
market: string | null;
|
||||
/** page: the claude.ai link. */
|
||||
url: string | null;
|
||||
files: { path: string; size: number }[];
|
||||
files: { path: string; size: number; mtime?: number }[];
|
||||
size: number | null;
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
@@ -4272,6 +4423,12 @@ export interface Artifact {
|
||||
inLibrary: boolean;
|
||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||
installed?: boolean;
|
||||
/** file: captured when its session stopped, not pinned by hand. */
|
||||
auto?: boolean;
|
||||
/** Server mode: nobody owns it yet. */
|
||||
unowned?: boolean;
|
||||
/** Unowned and offered to the caller to claim or decline. */
|
||||
suggestedForMe?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -4307,6 +4464,16 @@ export interface ArtifactCandidate {
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
||||
export interface OmpSharingState {
|
||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
broker: { running: boolean; error?: string | null };
|
||||
gateway: { running: boolean; port?: number; error?: string | null };
|
||||
}
|
||||
|
||||
export interface ArtifactSettings {
|
||||
serverMode: boolean;
|
||||
canShare: boolean;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file innerReposPref — whether the Changes panel lists the git repositories
|
||||
* INSIDE a workspace folder (a parent folder of several repos, one section
|
||||
* each via `GET /api/lanes/:id/repos`) or only the folder's own git. Off by
|
||||
* default ("own"): the panel neither walks the folder nor shows them until
|
||||
* the account turns it on in Settings → Git → Changes panel. One account
|
||||
* preference, `agenthub.changes.innerRepos` (`lib/userPrefs.ts`,
|
||||
* `server/routes/me.js`), read through `useSyncExternalStore` and re-read
|
||||
* when the account's preferences load (`PREFS_EVENT`) or another tab writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { PREFS_EVENT } from "./workspaceGroups";
|
||||
|
||||
export const INNER_REPOS_KEY = "agenthub.changes.innerRepos";
|
||||
export type InnerReposMode = "all" | "own";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function readInnerReposMode(): InnerReposMode {
|
||||
try {
|
||||
return localStorage.getItem(INNER_REPOS_KEY) === "all" ? "all" : "own";
|
||||
} catch {
|
||||
return "own";
|
||||
}
|
||||
}
|
||||
|
||||
export function writeInnerReposMode(mode: InnerReposMode): void {
|
||||
try {
|
||||
// The default is stored as no value, so an account that never chose keeps following it.
|
||||
if (mode === "all") localStorage.setItem(INNER_REPOS_KEY, "all");
|
||||
else localStorage.removeItem(INNER_REPOS_KEY);
|
||||
} catch {
|
||||
/* disabled storage: nothing to remember */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === INNER_REPOS_KEY || e.key === null) l();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(PREFS_EVENT, l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(PREFS_EVENT, l);
|
||||
};
|
||||
}
|
||||
|
||||
export function useInnerReposMode(): InnerReposMode {
|
||||
return useSyncExternalStore(subscribe, readInnerReposMode);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file markdownView — Settings → Appearance → "Open Markdown as": a viewer
|
||||
* preference in `localStorage` (`agenthub.markdown.view`, synced to the
|
||||
* account through `lib/userPrefs.ts`). Preview (the default) opens a `.md`
|
||||
* file rendered; Raw opens its source. Read each time a viewer opens a file
|
||||
* (the Files/Changes panel, the config file viewer, a chat artifact); the
|
||||
* viewer's own Preview/Raw toggle still flips that one file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const MARKDOWN_VIEW_KEY = "agenthub.markdown.view";
|
||||
|
||||
/** True when a Markdown file opens rendered (the default). */
|
||||
export function readMarkdownPreview(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(MARKDOWN_VIEW_KEY) !== "raw";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMarkdownPreview(on: boolean) {
|
||||
try {
|
||||
if (on) window.localStorage.removeItem(MARKDOWN_VIEW_KEY);
|
||||
else window.localStorage.setItem(MARKDOWN_VIEW_KEY, "raw");
|
||||
} catch {
|
||||
/* storage blocked — the default */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file nativePaste — Settings → Appearance → "Paste images natively": a
|
||||
* per-browser preference in `localStorage` (`agenthub.terminal.nativePaste`,
|
||||
* deliberately NOT synced to the account — it is about this browser's
|
||||
* machine). On (the default), a `claude` pane in a local-mode dashboard opened
|
||||
* from this machine's own browser gets an image paste as `^V`, so `claude`
|
||||
* reads the desktop clipboard itself like a native terminal — nothing is
|
||||
* uploaded or copied. Off, the image is uploaded like a remote browser's. An
|
||||
* SSH tunnel's browser also reaches the dashboard by loopback, and its `^V`
|
||||
* would read the SERVER's clipboard — that is what turning it off is for.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste";
|
||||
|
||||
/** True unless this browser turned native image paste off. */
|
||||
export function readNativePaste(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(NATIVE_PASTE_KEY) !== "off";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeNativePaste(on: boolean) {
|
||||
try {
|
||||
if (on) window.localStorage.removeItem(NATIVE_PASTE_KEY);
|
||||
else window.localStorage.setItem(NATIVE_PASTE_KEY, "off");
|
||||
} catch {
|
||||
/* storage blocked — the default */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file passwordRules.ts
|
||||
* @description The rules a password an account picks for ITSELF must pass —
|
||||
* a copy of `passwordProblems` in server/lib/auth.js, so the checklist can
|
||||
* tick each one as it is typed. The server's copy is the one that decides
|
||||
* (EWEAKPASSWORD); a password an admin gives someone else has no rules.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
export const PASSWORD_RULES = ["length", "lower", "upper", "digit", "symbol", "username"] as const;
|
||||
export type PasswordRule = (typeof PASSWORD_RULES)[number];
|
||||
|
||||
/** The rules `password` fails (empty = it passes every one). */
|
||||
export function passwordProblems(password: string, username?: string): PasswordRule[] {
|
||||
const out: PasswordRule[] = [];
|
||||
if ([...password].length < PASSWORD_MIN_LENGTH) out.push("length");
|
||||
if (!/\p{Ll}/u.test(password)) out.push("lower");
|
||||
if (!/\p{Lu}/u.test(password)) out.push("upper");
|
||||
if (!/\p{N}/u.test(password)) out.push("digit");
|
||||
if (!/[^\p{L}\p{N}]/u.test(password)) out.push("symbol");
|
||||
const name = (username || "").toLowerCase();
|
||||
if (name && password.toLowerCase().includes(name)) out.push("username");
|
||||
return out;
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
|
||||
* `@` form a file is handed to `claude` in, and what a browser on the
|
||||
* dashboard's own machine attaches by path instead of uploading
|
||||
* (`localCandidates`, checked by the server in `sameFilesHere` —
|
||||
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
|
||||
* file-manager copy carries —, checked by the server in `sameFilesHere` —
|
||||
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
|
||||
* sent with the prompt; that check is the one call here to the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -85,7 +86,47 @@ export function localCandidates(
|
||||
): string[] | null {
|
||||
if (!isLocalBrowser()) return null;
|
||||
const paths = droppedFilePaths(dt);
|
||||
return paths && paths.length === files.length ? paths : null;
|
||||
if (paths) return paths.length === files.length ? paths : null;
|
||||
// A PASTE of a file manager's copy has no uri-list: Chrome hands over the
|
||||
// files and the paths as plain text only. Kept only when the names match
|
||||
// the files one for one — `sameFilesHere` then checks the sizes.
|
||||
const copied = textFilePaths(dt);
|
||||
if (!copied || copied.length !== files.length) return null;
|
||||
const base = (p: string) => p.split(/[\\/]/).pop() ?? "";
|
||||
const want = files.map((f) => f.name).sort();
|
||||
const have = copied.map(base).sort();
|
||||
return have.every((n, i) => n === want[i]) ? copied : null;
|
||||
}
|
||||
|
||||
/** A paste's plain text as absolute paths — every non-empty line a `file://`
|
||||
* URI or an absolute path (a file manager's copy) — else null. */
|
||||
function textFilePaths(dt: DataTransfer | null | undefined): string[] | null {
|
||||
let lines: string[];
|
||||
try {
|
||||
lines = (dt?.getData("text/plain") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
if (lines.every((l) => l.startsWith("file://"))) {
|
||||
return droppedFilePaths({ getData: () => lines.join("\n") } as unknown as DataTransfer);
|
||||
}
|
||||
return lines.every((l) => /^(\/|[A-Za-z]:[\\/])/.test(l)) ? lines : null;
|
||||
}
|
||||
|
||||
/** The `file://` paths a paste carries as text with no file beside it — a
|
||||
* file manager's copy, as some browsers paste it — or null: its uri-list,
|
||||
* else its plain text when every line is one. Plain absolute paths are NOT
|
||||
* taken here: with no file to compare against, they are text to paste. */
|
||||
export function copiedFilePaths(dt: DataTransfer | null | undefined): string[] | null {
|
||||
const uris = droppedFilePaths(dt);
|
||||
if (uris) return uris;
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
if (!/^file:\/\//.test(text.trim())) return null;
|
||||
return textFilePaths(dt);
|
||||
}
|
||||
|
||||
/** Whether `paths` are the dropped `files` as the dashboard's machine sees
|
||||
|
||||
@@ -313,9 +313,21 @@ export const SETTINGS_INDEX: {
|
||||
to: "?tab=settings",
|
||||
},
|
||||
{
|
||||
section: "agents/claude-code",
|
||||
titleKey: "ccConfig:profiles.title",
|
||||
keywords: ["account", "login", "quota", "switch"],
|
||||
section: "agents",
|
||||
titleKey: "toolProfiles.title",
|
||||
keywords: [
|
||||
"profile",
|
||||
"account",
|
||||
"login",
|
||||
"quota",
|
||||
"switch",
|
||||
"codex",
|
||||
"omp",
|
||||
"pool",
|
||||
"hồ sơ",
|
||||
"tài khoản",
|
||||
],
|
||||
to: "#tool-profiles",
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
@@ -363,6 +375,12 @@ export const SETTINGS_INDEX: {
|
||||
},
|
||||
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
|
||||
{ section: "git", titleKey: "worktrees.agenthub", to: "#worktrees" },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "changesPanel.innerRepos",
|
||||
keywords: ["repositories", "nested", "monorepo", "changes"],
|
||||
to: "#changes-panel",
|
||||
},
|
||||
];
|
||||
|
||||
function tier(hay: string | undefined, q: string, exact: number): number {
|
||||
|
||||
@@ -1458,6 +1458,14 @@ export interface UpdateStatusPayload {
|
||||
plugin_root?: string;
|
||||
finished_at?: string;
|
||||
} | null;
|
||||
/** The step a running plugin-update chain is on (null between runs or on
|
||||
* a server that predates it). Polled while an update runs. */
|
||||
plugin_update_progress?: {
|
||||
step: string;
|
||||
index: number;
|
||||
total: number;
|
||||
started_at: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ───── Terminal run status ─────
|
||||
|
||||
@@ -20,6 +20,7 @@ import { api } from "./api";
|
||||
import i18n from "../i18n";
|
||||
import { applyStoredTheme } from "../hooks/useTheme";
|
||||
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
|
||||
import { PREFS_EVENT } from "./workspaceGroups";
|
||||
|
||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||
export const PREF_KEYS = [
|
||||
@@ -43,6 +44,9 @@ export const PREF_KEYS = [
|
||||
"agenthub.codex.bypass",
|
||||
"agenthub.tour",
|
||||
"agenthub.workspace.allTabs",
|
||||
"agenthub.markdown.view",
|
||||
"agenthub.workspace.groups",
|
||||
"agenthub.changes.innerRepos",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
@@ -115,6 +119,8 @@ function reapply(prefs: Record<string, string>) {
|
||||
window.dispatchEvent(new Event("sagi:prefs"));
|
||||
applyUiFontSize();
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
// Every other store that reads these keys (the sidebar's workspace groups).
|
||||
window.dispatchEvent(new Event(PREFS_EVENT));
|
||||
const lng = prefs.i18nextLng;
|
||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @file workspaceGroups — the sidebar's user-made workspace groups ("Frontend",
|
||||
* "Client A", …): their names, order and fold state, and which group each
|
||||
* workspace sits in. One JSON value under `agenthub.workspace.groups`, one of
|
||||
* the account preference keys (`lib/userPrefs.ts`, `server/routes/me.js`), so
|
||||
* the groups are kept per ACCOUNT on the server and follow it to any browser,
|
||||
* machine or port, and through updates. Like the drag order, it is a viewer's
|
||||
* arrangement, not a workspace fact: nothing about a lane changes, the CLI and
|
||||
* `GET /api/lanes` know nothing of it, and deleting a group only ungroups its
|
||||
* workspaces. A `useSyncExternalStore` reader cached on the raw string, so
|
||||
* every reader agrees; it re-reads when the account's preferences are loaded
|
||||
* (`PREFS_EVENT`) and when another tab writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const WORKSPACE_GROUPS_KEY = "agenthub.workspace.groups";
|
||||
/** Fired by `userPrefs.hydrate` once an account's values are in localStorage. */
|
||||
export const PREFS_EVENT = "agenthub:prefs";
|
||||
|
||||
export interface WorkspaceGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Folded shut in the sidebar. */
|
||||
folded?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceGroups {
|
||||
/** In display order. */
|
||||
groups: WorkspaceGroup[];
|
||||
/** Lane id → group id. A lane absent here, or naming no group, is ungrouped. */
|
||||
of: Record<string, string>;
|
||||
}
|
||||
|
||||
const EMPTY: WorkspaceGroups = { groups: [], of: {} };
|
||||
export const MAX_GROUP_NAME = 60;
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: { raw: string | null; value: WorkspaceGroups } = { raw: null, value: EMPTY };
|
||||
|
||||
function readRaw(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(WORKSPACE_GROUPS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parse(raw: string | null): WorkspaceGroups {
|
||||
if (!raw) return EMPTY;
|
||||
try {
|
||||
const v = JSON.parse(raw) as Partial<WorkspaceGroups>;
|
||||
const groups = (Array.isArray(v.groups) ? v.groups : []).filter(
|
||||
(g): g is WorkspaceGroup =>
|
||||
!!g && typeof g.id === "string" && !!g.id && typeof g.name === "string"
|
||||
);
|
||||
const ids = new Set(groups.map((g) => g.id));
|
||||
const of: Record<string, string> = {};
|
||||
if (v.of && typeof v.of === "object") {
|
||||
for (const [lane, gid] of Object.entries(v.of)) {
|
||||
if (typeof gid === "string" && ids.has(gid)) of[lane] = gid;
|
||||
}
|
||||
}
|
||||
return { groups, of };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorkspaceGroups(): WorkspaceGroups {
|
||||
const raw = readRaw();
|
||||
if (raw === cache.raw) return cache.value;
|
||||
cache = { raw, value: parse(raw) };
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
function write(next: WorkspaceGroups): void {
|
||||
try {
|
||||
localStorage.setItem(WORKSPACE_GROUPS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* disabled storage: the groups live until the page closes */
|
||||
cache = { raw: JSON.stringify(next), value: next };
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Change the groups through `fn` (given the current value, never mutated). */
|
||||
export function updateWorkspaceGroups(fn: (cur: WorkspaceGroups) => WorkspaceGroups): void {
|
||||
write(fn(readWorkspaceGroups()));
|
||||
}
|
||||
|
||||
const cleanName = (name: string) => name.trim().slice(0, MAX_GROUP_NAME);
|
||||
|
||||
/** A new group at the end, holding `laneIds` (moved out of their old group). Returns its id. */
|
||||
export function createGroup(name: string, laneIds: number[] = []): string {
|
||||
const id = `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
updateWorkspaceGroups((cur) => {
|
||||
const of = { ...cur.of };
|
||||
for (const lane of laneIds) of[lane] = id;
|
||||
return { groups: [...cur.groups, { id, name: cleanName(name) }], of };
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Rename; a blank name keeps the old one. */
|
||||
export function renameGroup(id: string, name: string): void {
|
||||
const clean = cleanName(name);
|
||||
if (!clean) return;
|
||||
updateWorkspaceGroups((cur) => ({
|
||||
...cur,
|
||||
groups: cur.groups.map((g) => (g.id === id ? { ...g, name: clean } : g)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Delete the group; its workspaces become ungrouped (nothing else changes). */
|
||||
export function deleteGroup(id: string): void {
|
||||
updateWorkspaceGroups((cur) => ({
|
||||
groups: cur.groups.filter((g) => g.id !== id),
|
||||
of: Object.fromEntries(Object.entries(cur.of).filter(([, gid]) => gid !== id)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function setGroupFolded(id: string, folded: boolean): void {
|
||||
updateWorkspaceGroups((cur) => ({
|
||||
...cur,
|
||||
groups: cur.groups.map((g) => (g.id === id ? { ...g, folded } : g)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Move the group `by` places (−1 up, +1 down), clamped. */
|
||||
export function moveGroup(id: string, by: number): void {
|
||||
updateWorkspaceGroups((cur) => {
|
||||
const from = cur.groups.findIndex((g) => g.id === id);
|
||||
const to = Math.max(0, Math.min(cur.groups.length - 1, from + by));
|
||||
if (from < 0 || from === to) return cur;
|
||||
const groups = [...cur.groups];
|
||||
groups.splice(to, 0, ...groups.splice(from, 1));
|
||||
return { ...cur, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/** Move the group to just before `beforeId` (a drag onto that header), or to the end (null). */
|
||||
export function moveGroupBefore(id: string, beforeId: string | null): void {
|
||||
updateWorkspaceGroups((cur) => {
|
||||
const moving = cur.groups.find((g) => g.id === id);
|
||||
if (!moving || id === beforeId) return cur;
|
||||
const rest = cur.groups.filter((g) => g.id !== id);
|
||||
const at = beforeId === null ? -1 : rest.findIndex((g) => g.id === beforeId);
|
||||
const groups = at < 0 ? [...rest, moving] : [...rest.slice(0, at), moving, ...rest.slice(at)];
|
||||
if (groups.every((g, i) => g === cur.groups[i])) return cur;
|
||||
return { ...cur, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/** Fold (or unfold) every group at once. */
|
||||
export function setAllFolded(folded: boolean): void {
|
||||
updateWorkspaceGroups((cur) => ({
|
||||
...cur,
|
||||
groups: cur.groups.map((g) => (!!g.folded === folded ? g : { ...g, folded })),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Put a workspace in a group, or (null) take it out of every group. */
|
||||
export function assignGroup(laneId: number, groupId: string | null): void {
|
||||
updateWorkspaceGroups((cur) => {
|
||||
const of = { ...cur.of };
|
||||
if (groupId && cur.groups.some((g) => g.id === groupId)) of[laneId] = groupId;
|
||||
else delete of[laneId];
|
||||
return { ...cur, of };
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget the lanes that no longer exist, so the stored value stays small. */
|
||||
export function pruneWorkspaceGroups(laneIds: number[]): void {
|
||||
const cur = readWorkspaceGroups();
|
||||
const live = new Set(laneIds.map(String));
|
||||
const stale = Object.keys(cur.of).filter((k) => !live.has(k));
|
||||
if (!stale.length) return;
|
||||
write({ ...cur, of: Object.fromEntries(Object.entries(cur.of).filter(([k]) => live.has(k))) });
|
||||
}
|
||||
|
||||
/**
|
||||
* `lanes` (already in the stored drag order) arranged by group: each group's
|
||||
* workspaces in group order, keeping their relative order, then the
|
||||
* ungrouped ones. Stable, never mutates.
|
||||
*/
|
||||
export function arrangeByGroup<T extends { id: number }>(lanes: T[], value: WorkspaceGroups): T[] {
|
||||
const rank = new Map(value.groups.map((g, i) => [g.id, i]));
|
||||
const at = (l: T) => {
|
||||
const gid = value.of[l.id];
|
||||
return gid !== undefined && rank.has(gid) ? rank.get(gid)! : value.groups.length;
|
||||
};
|
||||
return lanes
|
||||
.map((l, i) => ({ l, i }))
|
||||
.sort((a, b) => at(a.l) - at(b.l) || a.i - b.i)
|
||||
.map((x) => x.l);
|
||||
}
|
||||
|
||||
/** The group a workspace is in, or null. */
|
||||
export function groupOf(value: WorkspaceGroups, laneId: number): WorkspaceGroup | null {
|
||||
const gid = value.of[laneId];
|
||||
return (gid && value.groups.find((g) => g.id === gid)) || null;
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === WORKSPACE_GROUPS_KEY || e.key === null) l();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(PREFS_EVENT, l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(PREFS_EVENT, l);
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspaceGroups(): WorkspaceGroups {
|
||||
return useSyncExternalStore(subscribe, readWorkspaceGroups);
|
||||
}
|
||||
@@ -243,14 +243,19 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Switching tabs quickly must not show the previous tool's numbers.
|
||||
let cancelled = false;
|
||||
api.aiTools
|
||||
.usage(toolId)
|
||||
.then(setUsage)
|
||||
.catch(() => setUsage(null));
|
||||
.then((u) => !cancelled && setUsage(u))
|
||||
.catch(() => !cancelled && setUsage(null));
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes.filter((l) => l.tool === toolId)))
|
||||
.catch(() => setLanes([]));
|
||||
.then((r) => !cancelled && setLanes(r.lanes.filter((l) => l.tool === toolId)))
|
||||
.catch(() => !cancelled && setLanes([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [toolId]);
|
||||
|
||||
if (!tool) return null;
|
||||
|
||||
+373
-429
File diff suppressed because it is too large
Load Diff
+16
-497
@@ -10,8 +10,10 @@
|
||||
* concurrent-write races with the live CLI. Its Overview also holds the
|
||||
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
|
||||
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
|
||||
* The toolbar's Switch profile button links to Settings → AI tools → Profiles
|
||||
* (`ToolProfilesSettings`), where every AI tool's login profiles live.
|
||||
* Every card with action buttons (plugins, marketplaces, MCP servers, memory
|
||||
* files, auth profiles, and the `MdItemList` artifacts) also opens a
|
||||
* files, and the `MdItemList` artifacts) also opens a
|
||||
* right-click menu that repeats those buttons — same handlers, same
|
||||
* confirm path — plus Copy name/path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -61,7 +63,8 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import {
|
||||
Boxes,
|
||||
@@ -89,7 +92,6 @@ import {
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
Lock,
|
||||
History,
|
||||
Terminal,
|
||||
@@ -153,14 +155,11 @@ import type {
|
||||
CcPluginItem,
|
||||
CcPluginsResponse,
|
||||
CcUpdateAllBatch,
|
||||
CcProfile,
|
||||
CcAccountIdentity,
|
||||
CcProfileUsage,
|
||||
CcUsageWindow,
|
||||
CcScope,
|
||||
CcSettingsSource,
|
||||
CcStatusline,
|
||||
} from "../lib/api";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
|
||||
/** How an inline editor reports unsaved edits to the page. */
|
||||
type OnDirty = (id: string, dirty: boolean) => void;
|
||||
@@ -450,7 +449,6 @@ export function CcConfig() {
|
||||
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
|
||||
const setToast = showToast;
|
||||
const [backupsOpen, setBackupsOpen] = useState(false);
|
||||
const [profilesOpen, setProfilesOpen] = useState(false);
|
||||
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
|
||||
|
||||
const scopeRef = useRef(scope);
|
||||
@@ -682,7 +680,6 @@ export function CcConfig() {
|
||||
setAgenthubKey((k) => k + 1);
|
||||
}}
|
||||
onOpenBackups={() => setBackupsOpen(true)}
|
||||
onOpenProfiles={() => setProfilesOpen(true)}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
|
||||
@@ -811,7 +808,6 @@ export function CcConfig() {
|
||||
/>
|
||||
)}
|
||||
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
|
||||
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -823,20 +819,13 @@ interface HeaderProps {
|
||||
lastUpdated: Date | null;
|
||||
onRefresh: () => void;
|
||||
onOpenBackups: () => void;
|
||||
onOpenProfiles: () => void;
|
||||
wsConnected: boolean;
|
||||
}
|
||||
|
||||
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
|
||||
function Header({
|
||||
loading,
|
||||
lastUpdated,
|
||||
onRefresh,
|
||||
onOpenBackups,
|
||||
onOpenProfiles,
|
||||
wsConnected,
|
||||
}: HeaderProps) {
|
||||
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const settingsHref = useSettingsHref();
|
||||
const { t: tCommon } = useTranslation("common");
|
||||
const formatted = lastUpdated
|
||||
? lastUpdated.toLocaleTimeString(undefined, {
|
||||
@@ -863,14 +852,14 @@ function Header({
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 justify-end flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProfiles}
|
||||
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
|
||||
<Link
|
||||
to={settingsHref("agents#tool-profiles")}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<UserCog className="w-3.5 h-3.5" />
|
||||
{t("profiles.openButton")}
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBackups}
|
||||
@@ -1807,7 +1796,7 @@ function PluginsPanel({
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
if (isSubmitEnter(e)) void submit();
|
||||
}}
|
||||
placeholder={t("manage.installPlaceholder")}
|
||||
aria-label={t("manage.installTitle")}
|
||||
@@ -2340,7 +2329,7 @@ function McpPanel({
|
||||
value={commandLine}
|
||||
onChange={(e) => setCommandLine(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
if (isSubmitEnter(e)) void submit();
|
||||
}}
|
||||
placeholder={t("manage.addMcpCommandPlaceholder")}
|
||||
aria-label={t("mcp.command")}
|
||||
@@ -2351,7 +2340,7 @@ function McpPanel({
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
if (isSubmitEnter(e)) void submit();
|
||||
}}
|
||||
placeholder={t("manage.addMcpUrlPlaceholder")}
|
||||
aria-label={t("mcp.url")}
|
||||
@@ -4226,7 +4215,7 @@ function MarketplacesPanel({
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void add();
|
||||
if (isSubmitEnter(e)) void add();
|
||||
}}
|
||||
placeholder={t("manage.addMarketplacePlaceholder")}
|
||||
aria-label={t("manage.addMarketplaceTitle")}
|
||||
@@ -4737,476 +4726,6 @@ function CommandSnippet({ command, label }: { command: string; label?: string })
|
||||
);
|
||||
}
|
||||
|
||||
// ── Auth profiles modal ────────────────────────────────────────────────
|
||||
//
|
||||
// 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 — that is the whole point of the feature.
|
||||
//
|
||||
// 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" — hence a save button on the live account rather than an add-account
|
||||
// flow. There is no name field: the server derives the identifier, and the user
|
||||
// edits the display label in place by clicking it. That keeps the one string
|
||||
// the user types free of filename rules, so spaces and diacritics are fine.
|
||||
// The save button only appears while the live account is unsaved, because one
|
||||
// account is one profile — a second copy would just report itself active too.
|
||||
|
||||
function ProfilesModal({
|
||||
onClose,
|
||||
onToast,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onToast: (t: NonNullable<Toast>) => void;
|
||||
}) {
|
||||
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);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
/** Quota per profile, keyed by `name`, fetched for every profile once the
|
||||
* list arrives — the whole point of the panel is comparing accounts, and one
|
||||
* button per row made the user do that by hand. The cost is bounded: the
|
||||
* live account is read from the CLI and never refreshed, and any other
|
||||
* profile is refreshed only when its token has actually expired, which
|
||||
* leaves it valid for hours of further opens. */
|
||||
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");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const menuOpen = useRef(false);
|
||||
menuOpen.current = !!ctx.menu;
|
||||
// An open right-click menu takes this Escape for itself.
|
||||
useDialog(true, panelRef, () => {
|
||||
if (!menuOpen.current) onClose();
|
||||
});
|
||||
|
||||
/** Run one profile mutation, then refresh the list. */
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
onToast({ kind: "success", message: success });
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
onToast({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : "unknown error",
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[load, onToast]
|
||||
);
|
||||
|
||||
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, as soon as there is a list to ask about. Once per
|
||||
// profile per opening: 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 — a
|
||||
* redundant copy has to be removable, or an accidental duplicate could
|
||||
* never be cleaned up. */
|
||||
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
|
||||
const startRename = (p: CcProfile) => {
|
||||
setDraft(p.label);
|
||||
setRenaming(p.name);
|
||||
};
|
||||
const switchTo = (p: CcProfile) =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.ccConfig.activateProfile(p.name),
|
||||
t("profiles.switchSuccess", { name: p.label })
|
||||
);
|
||||
// A saved profile holds that account's OAuth credentials — deleting it
|
||||
// cannot be undone from here, so it asks first.
|
||||
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="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cc-profiles-title"
|
||||
className="relative w-full max-w-2xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<UserCog className="w-4 h-4 text-fg-muted" />
|
||||
<span id="cc-profiles-title" className="text-sm font-medium text-fg-primary flex-1">
|
||||
{t("profiles.title")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-b border-border space-y-2">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
{error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 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="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"
|
||||
>
|
||||
<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="rounded-lg border border-border bg-surface-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{renaming === p.name ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (e.key !== "Enter") return;
|
||||
const next = draft.trim();
|
||||
setRenaming(null);
|
||||
if (!next || next === p.label) return;
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: 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"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startRename(p)}
|
||||
title={t("profiles.renameHint")}
|
||||
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||
>
|
||||
{p.label}
|
||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
|
||||
</button>
|
||||
)}
|
||||
{p.active && (
|
||||
<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>
|
||||
)}
|
||||
<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="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy === `switch:${p.name}`
|
||||
? t("profiles.switching")
|
||||
: t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{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(startRename)}>{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>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
|
||||
<CommandSnippet command="claude auth login" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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.
|
||||
*
|
||||
* Deliberately not fetched with the profile list. Reading a saved profile's
|
||||
* usage means refreshing its OAuth token, and that rotates the credentials on
|
||||
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
|
||||
* on a render or 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>;
|
||||
}
|
||||
|
||||
// ── Backups modal ──────────────────────────────────────────────────────
|
||||
|
||||
function BackupsModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
@@ -95,6 +95,7 @@ import type {
|
||||
CodexScope,
|
||||
CodexSkill,
|
||||
} from "../lib/api";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
|
||||
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
|
||||
|
||||
@@ -1630,7 +1631,7 @@ function PluginsPanel({
|
||||
value={spec}
|
||||
onChange={(e) => setSpec(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submitPlugin();
|
||||
if (isSubmitEnter(e)) void submitPlugin();
|
||||
}}
|
||||
placeholder={t("plugins.installPlaceholder")}
|
||||
aria-label={t("plugins.installTitle")}
|
||||
@@ -1710,7 +1711,7 @@ function PluginsPanel({
|
||||
value={mktUrl}
|
||||
onChange={(e) => setMktUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submitMarketplace();
|
||||
if (isSubmitEnter(e)) void submitMarketplace();
|
||||
}}
|
||||
placeholder={t("plugins.addMarketplacePlaceholder")}
|
||||
aria-label={t("plugins.addMarketplaceTitle")}
|
||||
|
||||
@@ -1092,7 +1092,7 @@ function AnalyticsTab() {
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{loadError}</span>
|
||||
<button onClick={load} className="btn-ghost text-xs">
|
||||
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -3133,7 +3133,7 @@ export function Dashboard() {
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("failedLoad")}: {error}
|
||||
</span>
|
||||
<button onClick={load} className="btn-ghost text-xs">
|
||||
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -561,7 +561,7 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("failedList")}: {error}
|
||||
</span>
|
||||
<button onClick={load} className="btn-ghost text-xs">
|
||||
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +120,7 @@ import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
|
||||
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
|
||||
* it COUNTs whole tables, and hooks fire many times a second while sessions run. */
|
||||
@@ -1335,7 +1336,7 @@ function ClearDataDialog({
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && ok && !busy) onConfirm();
|
||||
if (isSubmitEnter(e) && ok && !busy) onConfirm();
|
||||
}}
|
||||
autoComplete="off"
|
||||
className="input mt-1 w-full font-mono text-sm"
|
||||
|
||||
@@ -69,8 +69,11 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import {
|
||||
rankSettings,
|
||||
@@ -83,6 +86,7 @@ import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAuth, usePermissions } from "../components/AuthGate";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
|
||||
const GROUPS = ["general", "agents", "data"] as const;
|
||||
type Group = (typeof GROUPS)[number];
|
||||
@@ -295,8 +299,12 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<AgentInstructionsSettings />
|
||||
<SessionDefaultsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* Every AI tool's login profiles, linked to the account pool. */}
|
||||
<ToolProfilesSettings />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
<OmpSharingSettings />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -417,6 +425,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<GitIdentitySettings />
|
||||
</section>
|
||||
)}
|
||||
<div id="changes-panel" className="scroll-mt-24">
|
||||
<ChangesPanelSettings />
|
||||
</div>
|
||||
<div id="worktrees" className="scroll-mt-24">
|
||||
<WorktreeVisibilitySettings />
|
||||
</div>
|
||||
@@ -509,7 +520,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]), nav);
|
||||
if (isSubmitEnter(e) && results[0]) navigate(hrefOf(results[0]), nav);
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* @description The Users tab of the Administration page (`/admin`, which
|
||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* role, state, workspaces, sessions, the tokens/$ it used today (the
|
||||
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
@@ -14,7 +14,8 @@
|
||||
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set; right-clicking a row offers
|
||||
* name and email under the username when set, and "New password due" while
|
||||
* the account still has a password an admin gave; right-clicking a row offers
|
||||
* Edit, Open home (in /files), and copying the username or email.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
@@ -213,13 +214,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
)}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[860px]" data-testid="users-table">
|
||||
<table className="w-full min-w-[960px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
<th className={th}>{t("role")}</th>
|
||||
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("today")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||
@@ -273,6 +275,15 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{!u.disabled && u.mustChangePassword && (
|
||||
<span
|
||||
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={t("mustChangeHint")}
|
||||
data-testid="user-must-change"
|
||||
>
|
||||
{t("mustChange")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
@@ -289,6 +300,7 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions}
|
||||
</td>
|
||||
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
<StorageCell
|
||||
|
||||
+131
-21
@@ -69,8 +69,10 @@ import {
|
||||
ChartColumn,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { api, type GitSource } from "../lib/api";
|
||||
import { useInnerReposMode } from "../lib/innerReposPref";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
@@ -940,6 +942,43 @@ export function Workspace() {
|
||||
);
|
||||
}, [panelLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||||
// The repos INSIDE the workspace folder (a parent folder of several repos):
|
||||
// one section each, read with `?repo=` (`GET /lanes/:id/repos`, re-read
|
||||
// with the panel's own refresh and every minute, so a clone shows up) —
|
||||
// unless the account chose the folder's own git only (Settings → Git →
|
||||
// Changes panel), which neither walks the folder nor shows them.
|
||||
const innerMode = useInnerReposMode();
|
||||
const [innerRepos, setInnerRepos] = useState<{
|
||||
laneId: number;
|
||||
root: boolean;
|
||||
items: { path: string; name: string }[];
|
||||
} | null>(null);
|
||||
const loadInnerRepos = useCallback((laneId: number, refresh = false) => {
|
||||
api.lanes.repos(laneId, refresh).then(
|
||||
(v) =>
|
||||
setInnerRepos((prev) =>
|
||||
prev?.laneId === laneId &&
|
||||
prev.root === v.root &&
|
||||
prev.items.map((r) => r.path).join("\n") === v.items.map((r) => r.path).join("\n")
|
||||
? prev
|
||||
: { laneId, root: v.root, items: v.items }
|
||||
),
|
||||
() => setInnerRepos({ laneId, root: true, items: [] })
|
||||
);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (panelLaneId === null || innerMode === "own") return;
|
||||
loadInnerRepos(panelLaneId);
|
||||
const timer = setInterval(() => loadInnerRepos(panelLaneId, true), 60_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [panelLaneId, loadInnerRepos, innerMode]);
|
||||
const inner = innerMode === "all" && innerRepos?.laneId === panelLaneId ? innerRepos : null;
|
||||
const repos = inner?.items ?? [];
|
||||
// A folder that is no repo itself but holds some: its own section would only
|
||||
// say "not a git repository", so the repos stand alone.
|
||||
const showRoot = !inner || inner.root || repos.length === 0;
|
||||
// A file the chat asks for lies in the inner repo whose path prefixes it.
|
||||
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
|
||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||
@@ -990,15 +1029,19 @@ export function Workspace() {
|
||||
null
|
||||
);
|
||||
const panelLaneRef = useRef<number | null>(null);
|
||||
const repoOfRef = useRef(repoOf);
|
||||
repoOfRef.current = repoOf;
|
||||
panelLaneRef.current = panelAt?.laneId ?? null;
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||
if (laneId !== panelLaneRef.current) return;
|
||||
// Its path is the workspace folder's: unfold that section.
|
||||
// Its path is the workspace folder's: unfold that section (or the
|
||||
// inner repo's that holds it).
|
||||
const repo = repoOfRef.current(path);
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete("");
|
||||
next.delete(repo ? `repo:${repo.path}` : "");
|
||||
return next;
|
||||
});
|
||||
setChangesOpen(true);
|
||||
@@ -1105,10 +1148,18 @@ export function Workspace() {
|
||||
setFeatures([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.lanes.features
|
||||
.list(currentLane.id)
|
||||
.then((data) => setFeatures(data.features))
|
||||
.catch(() => setFeatures([]));
|
||||
.then((data) => {
|
||||
if (!cancelled) setFeatures(data.features);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFeatures([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentLane?.id]);
|
||||
|
||||
// Fetch the archived snapshot when the picker selects one — read-only, never
|
||||
@@ -1138,10 +1189,18 @@ export function Workspace() {
|
||||
setProofFeatures([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.lanes.proof
|
||||
.list(currentLane.id)
|
||||
.then((data) => setProofFeatures(data.features))
|
||||
.catch(() => setProofFeatures([]));
|
||||
.then((data) => {
|
||||
if (!cancelled) setProofFeatures(data.features);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProofFeatures([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentLane?.id, viewedFeatureSlug]);
|
||||
|
||||
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
|
||||
@@ -1596,9 +1655,43 @@ export function Workspace() {
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
{panelAt &&
|
||||
[
|
||||
{ key: "", source: undefined as number | undefined },
|
||||
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
||||
].map(({ key, source }, i) => {
|
||||
...(showRoot
|
||||
? [
|
||||
{
|
||||
key: "",
|
||||
source: undefined as GitSource | undefined,
|
||||
name:
|
||||
currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() ||
|
||||
currentLane.cwd,
|
||||
root: undefined as string | undefined,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...repos.map((r) => ({
|
||||
key: `repo:${r.path}`,
|
||||
source: r.path as GitSource,
|
||||
name: r.name,
|
||||
root: `${currentLane.cwd.replace(/[\\/]+$/, "")}/${r.path}`,
|
||||
})),
|
||||
...folders.map((f) => ({
|
||||
key: String(f.id),
|
||||
source: f.id as GitSource,
|
||||
name: f.name,
|
||||
root: f.path,
|
||||
})),
|
||||
].map(({ key, source, name, root: dragRoot }, _i, sections) => {
|
||||
// The chat's file request goes to the section holding it.
|
||||
const req =
|
||||
source === undefined
|
||||
? changesRequest
|
||||
: typeof source === "string" && changesRequest
|
||||
? changesRequest.path.startsWith(`${source}/`)
|
||||
? {
|
||||
path: changesRequest.path.slice(source.length + 1),
|
||||
nonce: changesRequest.nonce,
|
||||
}
|
||||
: null
|
||||
: null;
|
||||
const panel = (
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous
|
||||
@@ -1611,8 +1704,8 @@ export function Workspace() {
|
||||
full={changesFull}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={dirtyFor(key)}
|
||||
openRequest={source ? null : changesRequest}
|
||||
filesChanged={source ? 0 : filesChanged}
|
||||
openRequest={req}
|
||||
filesChanged={source === undefined ? filesChanged : 0}
|
||||
// A sibling worktree forks from the lane's SOURCE repo
|
||||
// when it has one (this lane is itself a worktree of
|
||||
// it); an adopted lane is its own repo.
|
||||
@@ -1625,23 +1718,20 @@ export function Workspace() {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// No extra folders: the one panel, as it always was.
|
||||
if (!folders.length) return panel;
|
||||
// One repo only: the one panel, as it always was.
|
||||
if (sections.length === 1) return panel;
|
||||
// Else every repo is a section, stacked like VS Code's
|
||||
// Source Control; the open ones share the height. A
|
||||
// folded one stays mounted (CSS), so an unsaved edit in
|
||||
// it survives.
|
||||
const folded = foldedSources.has(key);
|
||||
const name = source
|
||||
? folders[i - 1]!.name
|
||||
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
|
||||
return (
|
||||
<section
|
||||
key={key}
|
||||
data-testid="panel-git-source"
|
||||
// A row dragged from an extra folder lies outside
|
||||
// the pane's folder: `dragProjectPath` makes it absolute.
|
||||
data-drag-root={source ? folders[i - 1]!.path : undefined}
|
||||
data-drag-root={dragRoot}
|
||||
className={`flex flex-col border-b border-border-light ${
|
||||
folded ? "shrink-0" : "min-h-0 flex-1"
|
||||
}`}
|
||||
@@ -1664,7 +1754,12 @@ export function Workspace() {
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
{typeof source === "string" && (
|
||||
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
)}
|
||||
<span className="truncate" title={dragRoot}>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
||||
{panel}
|
||||
@@ -1679,7 +1774,18 @@ export function Workspace() {
|
||||
onClose={sourceCtx.close}
|
||||
testId="panel-git-source-menu"
|
||||
>
|
||||
{sourceCtx.menu.item !== "" && (
|
||||
{innerMode === "all" && (
|
||||
<MenuItem
|
||||
testId="panel-git-source-rescan"
|
||||
onSelect={() => {
|
||||
sourceCtx.close();
|
||||
loadInnerRepos(panelAt.laneId, true);
|
||||
}}
|
||||
>
|
||||
{tLanes("changes.rescanRepos")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{/^\d+$/.test(sourceCtx.menu.item) && (
|
||||
<MenuItem
|
||||
testId="panel-git-source-close"
|
||||
onSelect={() => {
|
||||
@@ -1761,7 +1867,11 @@ export function Workspace() {
|
||||
</span>
|
||||
</button>
|
||||
{panelAt &&
|
||||
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
||||
[
|
||||
...(showRoot ? [undefined] : []),
|
||||
...repos.map((r): GitSource => r.path),
|
||||
...folders.map((f): GitSource => f.id),
|
||||
].map((source) => (
|
||||
<LaneGitRailCounts
|
||||
key={source ?? ""}
|
||||
laneId={panelAt.laneId}
|
||||
|
||||
@@ -84,7 +84,15 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
...api,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })) },
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: null })),
|
||||
ompSharing: vi.fn(async () => ({ available: false })),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -227,6 +227,66 @@ describe("Artifacts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("splits the Hub and My library into yours and others'", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({ id: 1, title: "my-page", shared: true }),
|
||||
item({
|
||||
id: 2,
|
||||
kind: "skill",
|
||||
title: "their-skill",
|
||||
mine: false,
|
||||
owner: "an",
|
||||
shared: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
// All is split too, others' first.
|
||||
const allOthers = await screen.findByTestId("half-all.others");
|
||||
expect(within(allOthers).getByText("their-skill")).toBeInTheDocument();
|
||||
expect(
|
||||
allOthers.compareDocumentPosition(screen.getByTestId("half-all.own")) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
await user.click(await screen.findByRole("button", { name: "Hub" }));
|
||||
const own = await screen.findByTestId("half-hub.own");
|
||||
expect(own).toHaveTextContent("Shared by me");
|
||||
expect(within(own).getByText("my-page")).toBeInTheDocument();
|
||||
const others = screen.getByTestId("half-hub.others");
|
||||
expect(within(others).getByText("their-skill")).toBeInTheDocument();
|
||||
expect(within(others).queryByText("my-page")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "My library" }));
|
||||
expect(await screen.findByTestId("half-mine.others")).toHaveTextContent(
|
||||
"Installed from others"
|
||||
);
|
||||
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pages a long section 12 cards at a time", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: Array.from({ length: 14 }, (_, i) =>
|
||||
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
|
||||
),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("p1");
|
||||
expect(screen.queryByText("p13")).toBeNull();
|
||||
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByText("p13")).toBeInTheDocument();
|
||||
expect(screen.queryByText("p1")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists who installed an artifact, when and where, from its install count", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })],
|
||||
@@ -287,13 +347,13 @@ describe("Artifacts", () => {
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed");
|
||||
expect(within(card).queryByRole("button", { name: "Install" })).toBeNull();
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.install).mockResolvedValueOnce({
|
||||
installed: "/h/.claude/skills/deploy",
|
||||
});
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" }));
|
||||
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
||||
// No Reinstall anywhere, and Uninstall lives only in the ⋯ menu.
|
||||
expect(within(card).queryByRole("button", { name: "Reinstall" })).toBeNull();
|
||||
expect(within(card).queryByRole("button", { name: "Uninstall" })).toBeNull();
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "More actions" }));
|
||||
const menu = screen.getByTestId("artifact-menu");
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Reinstall" })).toBeNull();
|
||||
expect(within(menu).getByRole("menuitem", { name: "Uninstall" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uninstalls an installed plugin only after a confirmation", async () => {
|
||||
@@ -307,7 +367,13 @@ describe("Artifacts", () => {
|
||||
const card = await screen.findByTestId("artifact-4");
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
||||
const uninstall = async () => {
|
||||
await user.click(within(card).getByRole("button", { name: "More actions" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Uninstall" })
|
||||
);
|
||||
};
|
||||
await uninstall();
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
expect(api.artifacts.uninstall).not.toHaveBeenCalled();
|
||||
|
||||
@@ -316,7 +382,7 @@ describe("Artifacts", () => {
|
||||
uninstalled: "tool@market",
|
||||
paths: [],
|
||||
});
|
||||
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
||||
await uninstall();
|
||||
await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file Covers the right-click menus on the Claude Code config page: a menu
|
||||
* item runs the SAME action as the card's button — a plugin's uninstall still
|
||||
* takes two clicks before it reaches the API, and a profile's delete calls the
|
||||
* same endpoint the trash button does.
|
||||
* takes two clicks before it reaches the API. (The profiles' menu moved with
|
||||
* them to Settings → AI tools: ToolProfilesSettings.test.tsx.)
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { settleConfirm } from "../../lib/confirm";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
@@ -124,41 +123,4 @@ describe("CcConfig — right-click menus", () => {
|
||||
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "uninstall");
|
||||
expect(screen.queryByTestId("plugin-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||
await settle();
|
||||
// A saved login is never deleted on one click: it asks first.
|
||||
expect(ccConfigMock.deleteProfile).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("offers no delete for the only saved copy of the live account", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* @file Covers the quota lines in the Claude Code auth-profile modal: opening
|
||||
* it asks for EVERY saved account's quota, not one row at a time.
|
||||
*
|
||||
* The one-at-a-time version made the user click through their own accounts to
|
||||
* answer the only question the panel exists for — which account still has room.
|
||||
* The other half is that each row fails on its own: an account whose saved
|
||||
* login has been superseded must not stop its neighbours from reporting.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
const window = (utilization: number) => ({
|
||||
utilization,
|
||||
resetsAt: "2026-09-18T08:00:00Z",
|
||||
});
|
||||
return {
|
||||
window,
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/.claude",
|
||||
projectClaudeDir: "/repo/.claude",
|
||||
projectRoot: "/repo",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
plugins: 0,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
settingsFiles: 0,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
skills: r({ items: [] }),
|
||||
agents: r({ items: [] }),
|
||||
commands: r({ items: [] }),
|
||||
outputStyles: r({ items: [] }),
|
||||
memory: r({ items: [] }),
|
||||
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
|
||||
marketplaces: r({ marketplaces: [] }),
|
||||
mcp: r({ mcp: [] }),
|
||||
hooks: r({ hooks: [] }),
|
||||
settings: r({ sources: [] }),
|
||||
keybindings: r({ groups: [] }),
|
||||
statusline: r({}),
|
||||
hookScripts: r({ items: [] }),
|
||||
backups: r({ items: [] }),
|
||||
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||
pluginsAvailable: r({ installed: [], available: [] }),
|
||||
updateAllStatus: r({ batch: null }),
|
||||
// The modal's own list: two saved accounts, one of them live.
|
||||
profiles: r({
|
||||
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" },
|
||||
}),
|
||||
profileUsage: vi.fn().mockImplementation(async (name: string) => {
|
||||
if (name === "spare") {
|
||||
throw new Error("this profile's saved login has been superseded");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
refreshed: false,
|
||||
fiveHour: window(8),
|
||||
sevenDay: window(70),
|
||||
checkedAt: "2026-09-17T16:00:00Z",
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CcConfig } from "../CcConfig";
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function openProfiles() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("CcConfig — auth profile quotas", () => {
|
||||
it("asks for every saved account's quota when the modal opens", async () => {
|
||||
await openProfiles();
|
||||
|
||||
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("work");
|
||||
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("spare");
|
||||
expect(await screen.findByText("8%")).toBeInTheDocument();
|
||||
expect(await screen.findByText("70%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the account that failed without costing the others their numbers", async () => {
|
||||
await openProfiles();
|
||||
|
||||
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("8%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks once per profile, however many times the panel re-renders", async () => {
|
||||
await openProfiles();
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
const asked = ccConfigMock.profileUsage.mock.calls.filter((c) => c[0] === "work");
|
||||
expect(asked).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
// AI tools → Profiles.
|
||||
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
||||
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,6 +54,12 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: null })),
|
||||
ompSharing: vi.fn(async () => ({ available: false })),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,6 +101,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
: [],
|
||||
})
|
||||
),
|
||||
// The repos inside the workspace folder (`?repo=`): none by default.
|
||||
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
|
||||
// Closing a folder's section removes it from the workspace.
|
||||
removeSource: r({
|
||||
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
||||
@@ -330,6 +332,79 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("neither walks a parent folder nor shows its repos until the account turns it on", async () => {
|
||||
vi.mocked(api.lanes.repos).mockClear();
|
||||
vi.mocked(api.lanes.repos).mockResolvedValue({
|
||||
root: false,
|
||||
items: [{ path: "api", name: "api" }],
|
||||
truncated: false,
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
const rail = screen.queryByTestId("lane-changes-expand");
|
||||
if (rail) {
|
||||
fireEvent.click(rail);
|
||||
await settle();
|
||||
}
|
||||
expect(api.lanes.repos).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
|
||||
});
|
||||
|
||||
it("gives each repo inside a parent folder its own section, without the folder's own", async () => {
|
||||
// Settings → Git → Changes panel: show the repositories inside.
|
||||
window.localStorage.setItem("agenthub.changes.innerRepos", "all");
|
||||
vi.mocked(api.lanes.repos).mockResolvedValue({
|
||||
root: false,
|
||||
items: [
|
||||
{ path: "api", name: "api" },
|
||||
{ path: "apps/web", name: "apps/web" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.git).mockClear();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
const rail = screen.queryByTestId("lane-changes-expand");
|
||||
if (rail) {
|
||||
fireEvent.click(rail);
|
||||
await settle();
|
||||
}
|
||||
const sections = screen.getAllByTestId("panel-git-source");
|
||||
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual([
|
||||
"api",
|
||||
"apps/web",
|
||||
]);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 2, repo: "apps/web" });
|
||||
// An inner repo is the folder's own: its menu rescans, never closes it.
|
||||
fireEvent.contextMenu(sections[1]!.querySelector("button")!);
|
||||
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
|
||||
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
|
||||
// A chat's file inside an inner repo opens in that repo's section.
|
||||
fireEvent.click(sections[1]!.querySelector("button")!);
|
||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||
act(() => {
|
||||
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(
|
||||
{ lane: 2, repo: "apps/web" },
|
||||
"src/x.ts",
|
||||
"work"
|
||||
);
|
||||
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
|
||||
});
|
||||
|
||||
it("is one plain panel for a workspace with no extra folders", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user