Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 477dd03d31 | |||
| 9e6f16400a | |||
| a31f314688 | |||
| 89f58a37e8 | |||
| f975066c62 | |||
| c557c0ad2f | |||
| d086a7f198 | |||
| 33bb3eccb5 | |||
| 4a665da79e | |||
| 1e3b34ec03 | |||
| decf176b4b | |||
| dea74b777e | |||
| 18e574a765 | |||
| 879a6bb02d | |||
| 1a365a45bd | |||
| 54001585e2 | |||
| 0bf552b793 | |||
| 87ba935c6e | |||
| 045e46e111 | |||
| e7573f13d9 | |||
| 9d28807f2c | |||
| e60ebe468b | |||
| 26977780cc | |||
| e06472ecd9 | |||
| ab27629139 | |||
| 3b1fdfac51 | |||
| 087c1ddb5d | |||
| 890cd08f57 | |||
| aabc95768c | |||
| b34b201bd1 | |||
| c356cf96a1 | |||
| 2a31537b80 | |||
| 0cd853e330 | |||
| 59533b997b | |||
| e21120d51d | |||
| c946b73d11 | |||
| 3daf7ed4cb | |||
| f9869ada6d | |||
| 0fc436dc0c | |||
| cd25791160 | |||
| 2a83551eee | |||
| d0889d5250 | |||
| 6958a7b4f6 | |||
| aa7f1b1c60 | |||
| 70d7cf30c2 | |||
| 5912e9e613 | |||
| 497faae3bf | |||
| e6ce8009b1 | |||
| 705353b0da | |||
| 0792c88612 | |||
| a3d1dce2ca | |||
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda | |||
| 1dd2521183 | |||
| 21a042dc2b | |||
| 85edc88a6d | |||
| c229b4b79b | |||
| 9721941962 | |||
| b777e08371 | |||
| d0b4f8aaf6 | |||
| 916505fbac | |||
| 75a4693f27 | |||
| cd6a1d36ff | |||
| 2342f54179 |
@@ -39,6 +39,16 @@
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Artifact|mcp__claude_ai_.*__batch",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/artifact-export-hint.js\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
|
||||
+33
-2
@@ -57,20 +57,48 @@ sudo pacman -S git tmux # Arch
|
||||
|
||||
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
|
||||
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
|
||||
|
||||
**PATH for `agenthub`:** put `export PATH="$HOME/.local/bin:$PATH"` in
|
||||
`~/.profile`, not only `~/.bashrc`. A login shell that is not interactive —
|
||||
`sudo -iu <user> agenthub remote login`, an `ssh host agenthub …`, a systemd
|
||||
unit — reads `~/.profile`, and Ubuntu's `~/.bashrc` returns early when the shell
|
||||
is not interactive, so a PATH set only there is never seen (`agenthub: command
|
||||
not found`). Or call it by its full path, `~/.local/bin/agenthub`.
|
||||
|
||||
**WSL2 with `networkingMode=mirrored`:** on some machines a Windows browser
|
||||
cannot reach WSL's `localhost`, so the dashboard must listen on an address
|
||||
Windows can reach. Save it once — every later start, the autostart unit and an
|
||||
update keep it:
|
||||
|
||||
```bash
|
||||
agenthub start --host 0.0.0.0 # every interface (LAN and VPN too)
|
||||
agenthub start --host 192.168.1.20 # or only this address (and 127.0.0.1)
|
||||
```
|
||||
|
||||
A non-loopback bind always gets a token (generated when you give none; `--token
|
||||
<t>` sets your own, `--token off` removes it) and `agenthub start` prints the
|
||||
links to open, each carrying `?token=` — the page keeps it and drops it from
|
||||
the address bar. The server also listens on `127.0.0.1` whenever the bind does
|
||||
not cover it, so hooks and the CLI keep working. `agenthub start --host local`
|
||||
goes back to loopback only.
|
||||
On a native Windows install, workspace terminals are unavailable, but Settings →
|
||||
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
|
||||
and runs a `.cmd` updater or npm through `cmd.exe`.
|
||||
|
||||
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
|
||||
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
|
||||
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release:
|
||||
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release
|
||||
into your own `~/.local` — the system's tmux stays as it is:
|
||||
|
||||
```bash
|
||||
sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison
|
||||
curl -fsSL https://github.com/tmux/tmux/releases/download/3.7c/tmux-3.7c.tar.gz | tar xz
|
||||
cd tmux-3.7c && ./configure && make && sudo make install # into /usr/local/bin
|
||||
cd tmux-3.7c && ./configure --prefix="$HOME/.local" && make && make install # into ~/.local/bin
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile # ahead of /usr/bin
|
||||
```
|
||||
|
||||
(`sudo make install` instead puts it in `/usr/local/bin` for every account.)
|
||||
|
||||
A tmux server already running keeps its old version until every tmux session
|
||||
has closed (or the machine reboots) — only then do the scrollbars appear.
|
||||
|
||||
@@ -444,6 +472,9 @@ npm run install-hooks
|
||||
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
|
||||
| `DASHBOARD_HOST` | `127.0.0.1` | Address(es) to listen on, comma-separated (`192.168.1.20,100.64.0.7`); `127.0.0.1` is added whenever none covers it. Saved by `agenthub start --host` in `<data dir>/network.json`; the variable wins over the file |
|
||||
| `DASHBOARD_ALLOWED_HOSTS` | — | Extra `Host` names (comma-separated) for a LAN bind. Saved by `agenthub start --allowed-hosts` |
|
||||
| `DASHBOARD_TOKEN` | — | Access token for `/api/*` and the WebSockets. Saved by `agenthub start --token`; the running server publishes it in `<data dir>/runtime/dashboard-token` (0600) for the local CLI, MCP server and statusline |
|
||||
|
||||
Example with a custom port:
|
||||
|
||||
|
||||
+196
-1
@@ -147,6 +147,11 @@ function baseUrl() {
|
||||
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
|
||||
}
|
||||
|
||||
/** The dashboard-token header (`dashboard-url.js`), {} when none is set. */
|
||||
function dashboardTokenHeaders() {
|
||||
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).tokenHeaders();
|
||||
}
|
||||
|
||||
/** Thrown when the dashboard server does not answer — the dispatcher decides
|
||||
* whether the active command has an offline fallback or must abort. */
|
||||
class ServerDownError extends Error {}
|
||||
@@ -162,6 +167,8 @@ async function api(method, pathname, body, options = {}) {
|
||||
method,
|
||||
headers: {
|
||||
...(body && !form ? { "Content-Type": "application/json" } : {}),
|
||||
// DASHBOARD_TOKEN, else the one the running server published.
|
||||
...dashboardTokenHeaders(),
|
||||
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||
...(process.env.AGENTHUB_PANE_TOKEN
|
||||
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
|
||||
@@ -1133,6 +1140,107 @@ function renderPricingTable(rules) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub remote login|status|logout|upload` — this LOCAL AgentHub's link
|
||||
* to the company hub (server/lib/remote-client.js, through the running
|
||||
* dashboard's /api/remote-client). The password goes to the hub once and is
|
||||
* never stored; only a device token is, which no command prints.
|
||||
*/
|
||||
async function cmdRemote(flags, positional) {
|
||||
const sub = positional[0] || "status";
|
||||
const show = (st) => {
|
||||
if (!st.enrolled) {
|
||||
console.log(
|
||||
st.expired
|
||||
? c.yellow(
|
||||
`This machine's hub login expired (${st.hub}) — run: agenthub remote login ${st.hub}`
|
||||
)
|
||||
: "Not enrolled with a company hub — run: agenthub remote login <hub url>"
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`${c.green("●")} Enrolled with ${c.bold(st.hub)} as ${c.bold(st.user?.username || "?")}`
|
||||
);
|
||||
console.log(
|
||||
c.dim(
|
||||
` device ${st.device?.name} (#${st.device?.id}), valid until ${new Date(st.device?.expiresAt).toLocaleString()}`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
c.dim(
|
||||
" Only sessions started on the Company account send their conversation and tokens to the hub (hourly)."
|
||||
)
|
||||
);
|
||||
};
|
||||
if (sub === "login") {
|
||||
const hub = positional[1];
|
||||
if (!hub) {
|
||||
console.error(
|
||||
c.red("✖ usage: agenthub remote login <hub url> [--user NAME] [--name DEVICE]")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
let username = flags.user ? String(flags.user) : "";
|
||||
if (!username) {
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(c.red("✖ --user is required when the password comes from stdin"));
|
||||
process.exit(1);
|
||||
}
|
||||
const rl = require("node:readline").createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
username = await new Promise((r) =>
|
||||
rl.question("Hub username: ", (a) => (rl.close(), r(a.trim())))
|
||||
);
|
||||
}
|
||||
const password = await askPassword(`Hub password for ${username}: `);
|
||||
const r = await api(
|
||||
"POST",
|
||||
"/api/remote-client/login",
|
||||
{ hub, username, password, deviceName: flags.name ? String(flags.name) : undefined },
|
||||
{ allowError: true, timeoutMs: 30_000 }
|
||||
);
|
||||
if (r && r.status) {
|
||||
console.error(c.red(`✖ ${r.data?.error?.message || `HTTP ${r.status}`}`));
|
||||
process.exit(1);
|
||||
}
|
||||
show(r);
|
||||
return;
|
||||
}
|
||||
if (sub === "logout") {
|
||||
const r = await post("/api/remote-client/logout", {});
|
||||
console.log(
|
||||
r.wasEnrolled
|
||||
? `${c.green("✔")} Logged out${r.revokedOnHub ? " — the hub revoked this device" : c.yellow(" — the hub could not be reached; the token stops working when it expires or an admin revokes it")}`
|
||||
: "This machine was not enrolled."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "upload") {
|
||||
const r = await post("/api/remote-client/upload", {}, { timeoutMs: 600_000 });
|
||||
console.log(`${c.green("✔")} Sent ${r.bytes} bytes of ${r.sessions} session(s)`);
|
||||
for (const e of r.errors || []) console.log(c.yellow(` ${e.sessionId}: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
if (sub === "status") {
|
||||
const st = await get("/api/remote-client");
|
||||
if (flags.json) return console.log(JSON.stringify(st, null, 2));
|
||||
show(st);
|
||||
for (const u of (st.uploads || []).slice(0, 10)) {
|
||||
console.log(
|
||||
c.dim(
|
||||
` ${u.sessionId.slice(0, 8)} ${String(u.bytes || 0).padStart(9)} B ${u.lastSentAt || "not sent yet"} ${u.cwd || ""}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(c.red(`✖ unknown: agenthub remote ${sub} (login | status | logout | upload)`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function cmdPricing(flags, positional) {
|
||||
const sub = positional[0];
|
||||
if (sub === "set" && positional[1]) {
|
||||
@@ -1394,6 +1502,64 @@ async function cmdReinstallHooks() {
|
||||
console.log(`${c.green("✔")} Claude Code hooks reinstalled`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save `start`'s network flags into `<data dir>/network.json` (applied by every
|
||||
* later server start): `--host A[,B]` (`local` = back to loopback only),
|
||||
* `--allowed-hosts H,..` (`none` clears), `--token T|auto|off`. A wide bind
|
||||
* with no token gets one made (`auto`), never left open silently. Returns
|
||||
* whether anything changed.
|
||||
*/
|
||||
function saveNetworkFlags(flags) {
|
||||
const net = require(path.join(REPO_ROOT, "server", "lib", "network-config.js"));
|
||||
const patch = {};
|
||||
if (typeof flags.host === "string") {
|
||||
patch.host = flags.host === "local" ? null : flags.host;
|
||||
} else if (flags.host === true) {
|
||||
console.error(c.red("✖ --host needs an address, e.g. --host 0.0.0.0 (or --host local)"));
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof flags["allowed-hosts"] === "string") {
|
||||
patch.allowedHosts = flags["allowed-hosts"] === "none" ? null : flags["allowed-hosts"];
|
||||
}
|
||||
if (flags.token === "off") patch.token = null;
|
||||
else if (flags.token === "auto" || flags.token === true) patch.token = net.newToken();
|
||||
else if (typeof flags.token === "string") patch.token = flags.token;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
net.applyNetworkConfig();
|
||||
return false;
|
||||
}
|
||||
const saved = net.writeNetworkConfig(patch);
|
||||
const wide = (saved.host || "")
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.some((h) => h && !["127.0.0.1", "localhost", "::1", "[::1]"].includes(h));
|
||||
if (wide && !saved.token && !process.env.DASHBOARD_TOKEN) {
|
||||
// Reachable from the network with no token would be an open dashboard.
|
||||
Object.assign(saved, net.writeNetworkConfig({ token: net.newToken() }));
|
||||
console.log(c.yellow(" A token was generated: the dashboard is reachable from the network."));
|
||||
}
|
||||
console.log(`${c.green("✔")} Network settings saved to ${c.dim(net.configPath())}`);
|
||||
net.applyNetworkConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print every URL the dashboard can be opened at (all of this machine's
|
||||
* addresses for a 0.0.0.0 bind), each carrying `?token=` when one is set —
|
||||
* the page stores it and drops it from the address bar.
|
||||
*/
|
||||
function printAccessUrls() {
|
||||
const sec = require(path.join(REPO_ROOT, "server", "lib", "security.js"));
|
||||
const port = Number(new URL(baseUrl()).port) || 4820;
|
||||
const token = sec.getDashboardToken();
|
||||
const urls = sec.accessUrls(port);
|
||||
if (urls.length <= 1 && !token) return;
|
||||
console.log(c.dim(" Open it at:"));
|
||||
for (const u of urls) {
|
||||
console.log(` ${c.bold(token ? `${u}/?token=${encodeURIComponent(token)}` : u)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the dashboard server in the background (production mode, serving the
|
||||
* built client) and wait until /api/health answers. No-ops with a pointer to
|
||||
@@ -1404,8 +1570,16 @@ async function cmdReinstallHooks() {
|
||||
async function cmdStart(flags) {
|
||||
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
|
||||
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
|
||||
const changed = saveNetworkFlags(flags);
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
||||
if (changed) {
|
||||
console.log(
|
||||
c.yellow(" The new network settings were saved; they apply at the next start: ") +
|
||||
c.bold("agenthub stop && agenthub start")
|
||||
);
|
||||
}
|
||||
printAccessUrls();
|
||||
return;
|
||||
}
|
||||
// A plugin install starts the way a Claude Code session does: its bootstrap
|
||||
@@ -1422,6 +1596,7 @@ async function cmdStart(flags) {
|
||||
while (Date.now() < deadline) {
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
|
||||
printAccessUrls();
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
@@ -1471,6 +1646,7 @@ async function cmdStart(flags) {
|
||||
console.log(
|
||||
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
|
||||
);
|
||||
printAccessUrls();
|
||||
console.log(c.dim(" Stop it with: agenthub stop"));
|
||||
return;
|
||||
}
|
||||
@@ -2827,7 +3003,11 @@ const COMMAND_GROUPS = [
|
||||
"Server",
|
||||
[
|
||||
["status", "", "Up/down indicator for the dashboard server"],
|
||||
["start", "[--port N]", "Start the server in the background and wait for healthy"],
|
||||
[
|
||||
"start",
|
||||
"[--port N] [--host A[,B]|local] [--allowed-hosts H,..] [--token T|auto|off]",
|
||||
"Start the server in the background and wait for healthy; --host/--allowed-hosts/--token are saved (data dir network.json) for every later start",
|
||||
],
|
||||
[
|
||||
"stop",
|
||||
"",
|
||||
@@ -2934,6 +3114,19 @@ const COMMAND_GROUPS = [
|
||||
["remote-sources rm <id>", "[--purge]", "Remove a source (--purge also deletes its data)"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Company hub",
|
||||
[
|
||||
[
|
||||
"remote login <hub url>",
|
||||
"[--user NAME --name DEVICE]",
|
||||
"Enroll this machine (Company account)",
|
||||
],
|
||||
["remote status", "[--json]", "Enrollment and what was sent to the hub"],
|
||||
["remote upload", "", "Send the Company-account sessions' new lines now"],
|
||||
["remote logout", "", "Revoke this machine on the hub and forget it"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Workspaces",
|
||||
[
|
||||
@@ -4549,6 +4742,8 @@ async function runCommand(argv) {
|
||||
case "remote-sources":
|
||||
case "remotes":
|
||||
return cmdRemoteSources(flags, positional);
|
||||
case "remote":
|
||||
return cmdRemote(flags, positional);
|
||||
case "pricing":
|
||||
return cmdPricing(flags, positional);
|
||||
case "import":
|
||||
|
||||
Generated
+247
@@ -25,6 +25,7 @@
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
@@ -1194,6 +1195,241 @@
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.1.0.tgz",
|
||||
"integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.1.0",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.1.0",
|
||||
"@napi-rs/canvas-darwin-x64": "1.1.0",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.1.0",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.1.0",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.1.0",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.1.0",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.1.0",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.1.0",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz",
|
||||
"integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz",
|
||||
"integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz",
|
||||
"integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz",
|
||||
"integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz",
|
||||
"integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz",
|
||||
"integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz",
|
||||
"integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz",
|
||||
"integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz",
|
||||
"integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -5406,6 +5642,17 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.4.299",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
|
||||
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
|
||||
@@ -132,6 +132,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
||||
"cost_threshold",
|
||||
"budget",
|
||||
];
|
||||
/** Only a hub (server mode) has remote clients to alert about. */
|
||||
const SERVER_RULE_TYPES: AlertRuleType[] = ["remote_unmonitored"];
|
||||
|
||||
type TabKey = "rules" | "channels" | "activity";
|
||||
|
||||
@@ -243,6 +245,8 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
if (form.budget_tool) config.tool = form.budget_tool;
|
||||
return config;
|
||||
}
|
||||
case "remote_unmonitored":
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +282,8 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
|
||||
period: t(`periods.${c.period ?? "month"}`),
|
||||
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
||||
});
|
||||
case "remote_unmonitored":
|
||||
return t("ruleDesc.remoteUnmonitored");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +292,8 @@ export function AlertsNotifications() {
|
||||
const { t: ts } = useTranslation("settings");
|
||||
|
||||
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const ruleTypes = local ? RULE_TYPES : [...RULE_TYPES, ...SERVER_RULE_TYPES];
|
||||
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||
|
||||
// Rules
|
||||
@@ -613,7 +620,7 @@ export function AlertsNotifications() {
|
||||
title={editingId ? t("rules.typeFixed") : undefined}
|
||||
className="select w-full"
|
||||
>
|
||||
{RULE_TYPES.map((type) => (
|
||||
{ruleTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`ruleTypes.${type}`)}
|
||||
</option>
|
||||
|
||||
@@ -11,6 +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`).
|
||||
* With DASHBOARD_TOKEN set, a 401 `EUNAUTHORIZED` (from `/auth/me` or any
|
||||
* request, `TOKEN_REQUIRED_EVENT`) shows the token prompt instead: the token is
|
||||
* kept in this browser and the page reloads with it.
|
||||
* 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.
|
||||
@@ -30,7 +33,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { FirstPasswordPage } from "./FirstPasswordPage";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import {
|
||||
api,
|
||||
AUTH_REQUIRED_EVENT,
|
||||
dashboardToken,
|
||||
saveDashboardToken,
|
||||
TOKEN_REQUIRED_EVENT,
|
||||
type AuthState,
|
||||
type AuthUser,
|
||||
} from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = {
|
||||
@@ -72,6 +83,10 @@ export function usePermissions() {
|
||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||
// Owner-only actions on a workspace (rename, reset, remove, share): its
|
||||
// owner or an admin. A workspace shared with this user is not theirs.
|
||||
ownsLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || (!!user && lane?.owner_id === user.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,6 +106,8 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// null = still asking the server. A browser that last saw local mode starts
|
||||
// there and is corrected by the answer (or by a 401) if that changed.
|
||||
const [state, setState] = useState<AuthState | null>(cachedState);
|
||||
// DASHBOARD_TOKEN is set and this browser's token is missing or wrong.
|
||||
const [needToken, setNeedToken] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | undefined;
|
||||
@@ -109,6 +126,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
})
|
||||
.catch((err: { status?: number; code?: string }) => {
|
||||
if (!alive) return;
|
||||
if (err?.status === 401 && err?.code === "EUNAUTHORIZED") {
|
||||
setNeedToken(true);
|
||||
return;
|
||||
}
|
||||
// An answer that is not this build's: an older server still running
|
||||
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
||||
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
||||
@@ -130,10 +151,13 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// Any request answering 401 EAUTH means server mode with no valid login.
|
||||
const onRequired = () => setState({ mode: "server", user: null });
|
||||
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
const onToken = () => setNeedToken(true);
|
||||
window.addEventListener(TOKEN_REQUIRED_EVENT, onToken);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
window.removeEventListener(TOKEN_REQUIRED_EVENT, onToken);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -158,6 +182,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
[]
|
||||
);
|
||||
|
||||
if (needToken) return <TokenPage />;
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
@@ -262,3 +287,65 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DASHBOARD_TOKEN's own prompt: the token is kept in this browser
|
||||
* (`localStorage.dashboard_token`) and the page reloads, so every request,
|
||||
* WebSocket and image starts again with it. A token already saved here that
|
||||
* the server refused is shown as wrong.
|
||||
*/
|
||||
function TokenPage() {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState("");
|
||||
const [show, setShow] = useState(false);
|
||||
const hadOne = Boolean(dashboardToken());
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = token.trim();
|
||||
if (!value) return;
|
||||
saveDashboardToken(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthCard title={t("auth.tokenTitle")} subtitle={t("auth.tokenSubtitle")} onSubmit={submit}>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="dashboard-token">{t("auth.tokenLabel")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="dashboard-token"
|
||||
className="input h-11 w-full pl-4 pr-11 font-mono"
|
||||
type={show ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((v) => !v)}
|
||||
aria-label={show ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={show}
|
||||
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"
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{hadOne && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{t("auth.tokenWrong")}
|
||||
</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"
|
||||
>
|
||||
{t("auth.tokenSubmit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.tokenHint")}</p>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file NeedsYouStrip.tsx
|
||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
||||
* blocked on the user (`awaiting_input_since`), each linking straight to its
|
||||
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
|
||||
* admin's lists no other user's), each linking straight to its
|
||||
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 20 })
|
||||
.list({ awaiting: true, limit: 20, mine: true })
|
||||
.then((r) => {
|
||||
setSessions(r.sessions);
|
||||
setTotal(r.total);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file PdfView.tsx
|
||||
* @description A PDF drawn by pdf.js onto canvases, page after page — the
|
||||
* Artifacts preview's PDF viewer. The browser's own viewer cannot run inside
|
||||
* the sandbox the preview keeps (it blocks plugins), and opening a shared PDF
|
||||
* on the dashboard's origin would be the risk the sandbox exists for; pdf.js
|
||||
* only DRAWS the file (no scripting sandbox loaded, no XFA, no links
|
||||
* followed), so nothing in it runs. pdf.js and its worker are loaded the
|
||||
* first time a PDF is opened (a separate chunk), never with the dashboard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/** Pages drawn at most: a 500-page export would otherwise hold every canvas. */
|
||||
const MAX_PAGES = 200;
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
async function loadPdfJs() {
|
||||
// The legacy build: the modern one needs `Promise.try` and friends, which a
|
||||
// browser a year old lacks — the PDF would then just fail to open.
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
import.meta.url
|
||||
).toString();
|
||||
}
|
||||
return pdfjs;
|
||||
}
|
||||
|
||||
export function PdfView({ url, title }: { url: string; title: string }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "failed">("loading");
|
||||
const [pages, setPages] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
let destroy: (() => void) | null = null;
|
||||
setState("loading");
|
||||
(async () => {
|
||||
const res = await fetch(url, { credentials: "same-origin" });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
if (data.byteLength > MAX_BYTES) throw new Error("too big");
|
||||
const pdfjs = await loadPdfJs();
|
||||
const task = pdfjs.getDocument({
|
||||
data,
|
||||
enableXfa: false,
|
||||
disableAutoFetch: true,
|
||||
});
|
||||
destroy = () => void task.destroy();
|
||||
const doc = await task.promise;
|
||||
if (!live || !box.current) return;
|
||||
setPages(doc.numPages);
|
||||
const width = Math.max(320, box.current.clientWidth - 32);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
box.current.replaceChildren();
|
||||
for (let n = 1; n <= Math.min(doc.numPages, MAX_PAGES); n++) {
|
||||
const page = await doc.getPage(n);
|
||||
if (!live || !box.current) return;
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const viewport = page.getViewport({ scale: (width / base.width) * ratio });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
canvas.style.width = `${Math.floor(viewport.width / ratio)}px`;
|
||||
canvas.className = "mx-auto mb-3 block bg-white shadow";
|
||||
canvas.setAttribute("aria-label", t("pdf.page", { n }));
|
||||
box.current.appendChild(canvas);
|
||||
await page.render({ canvas, viewport }).promise;
|
||||
if (n === 1 && live) setState("ready");
|
||||
}
|
||||
})().catch(() => live && setState("failed"));
|
||||
return () => {
|
||||
live = false;
|
||||
destroy?.();
|
||||
};
|
||||
}, [url, t]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto bg-surface-2 p-4" data-testid="pdf-view">
|
||||
{state === "loading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-fg-muted">
|
||||
<Loader2 className="h-5 w-5 animate-spin" aria-label={t("loading")} />
|
||||
</div>
|
||||
)}
|
||||
{state === "failed" && (
|
||||
<p className="p-6 text-center text-sm text-fg-secondary">{t("pdf.failed")}</p>
|
||||
)}
|
||||
<div ref={box} role="document" aria-label={title} />
|
||||
{pages > MAX_PAGES && (
|
||||
<p className="py-2 text-center text-xs text-fg-muted">
|
||||
{t("pdf.more", { shown: MAX_PAGES, total: pages })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window — its whole UI: clicking the pet opens
|
||||
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
|
||||
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
|
||||
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
|
||||
* who are you…) is answered right here; anything else — and every message once
|
||||
* a chat runs — goes to the AI: the first message starts a
|
||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
||||
* reopening the window continues it; **New chat** STOPS that session (kills
|
||||
* its pane and archives it, asking first only mid-reply), **Open in workspace**
|
||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||
* is gone, the next message starts a new one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
stepRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { TerminalView } from "../run/TerminalView";
|
||||
import { PaneMenuCard } from "../run/PaneChat";
|
||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||
|
||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
const CHAT_KEY = "agenthub.pet.chat";
|
||||
const POLL_MS = 1500;
|
||||
const MENU_POLL_MS = 700;
|
||||
// A follow-up waits this long for the off-screen pane to attach.
|
||||
const SEND_TRIES = 20;
|
||||
const SEND_RETRY_MS = 250;
|
||||
|
||||
/** The session the chat is talking to. */
|
||||
export interface PetChatRef {
|
||||
laneId: number;
|
||||
runId: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export function readPetChat(): PetChatRef | null {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||
if (
|
||||
v &&
|
||||
typeof v.laneId === "number" &&
|
||||
typeof v.runId === "string" &&
|
||||
typeof v.tool === "string"
|
||||
)
|
||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||
} catch {
|
||||
/* unreadable: no chat */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePetChat(ref: PetChatRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||
else localStorage.removeItem(CHAT_KEY);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||
export function petChatTool(): string | null {
|
||||
const tool = readDefaultTool() ?? "claude";
|
||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||
}
|
||||
|
||||
type Line =
|
||||
| { key: string; role: "user" | "assistant"; text: string }
|
||||
| { key: string; role: "tools"; names: string[] };
|
||||
|
||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||
* `<system-reminder>`…) are not something the user said. */
|
||||
const humanText = (m: TranscriptMessage) =>
|
||||
m.content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!.trim())
|
||||
.filter((t) => t && !t.startsWith("<"))
|
||||
.join("\n\n");
|
||||
|
||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||
* each run of tool calls between them folded into one chip. */
|
||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||
const out: Line[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
const sender = m.sender ?? m.type;
|
||||
if (sender === "user") {
|
||||
const text = humanText(m);
|
||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||
return;
|
||||
}
|
||||
if (m.type !== "assistant") return;
|
||||
m.content.forEach((c, j) => {
|
||||
if (c.type === "text" && c.text?.trim()) {
|
||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||
} else if (c.type === "tool_use" && c.name) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "tools") last.names.push(c.name);
|
||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
const sender = m.sender ?? m.type;
|
||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||
if (sender === "user" && humanText(m)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** "Read ×2 · Bash" */
|
||||
function toolSummary(names: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||
}
|
||||
|
||||
/** What the container made of a message: an answer the pet gives itself, or
|
||||
* `handled` (it took the message elsewhere); null sends it to the AI. */
|
||||
export type PetLocalReply = { answer: string } | { handled: true } | null;
|
||||
|
||||
interface PetChatProps {
|
||||
/** The pet's shown name, for the header and greeting. */
|
||||
name: string;
|
||||
/** Shown in place of the plain name (the rename button). */
|
||||
title?: ReactNode;
|
||||
/** Extra header buttons, before Open in workspace (the pat heart). */
|
||||
headerActions?: ReactNode;
|
||||
/** Under the header: what the pet knows (status, sessions that need you). */
|
||||
info?: ReactNode;
|
||||
/** Asked first while no chat runs. */
|
||||
onLocal?: (text: string) => PetLocalReply;
|
||||
onClose: () => void;
|
||||
/** Opens the chat's pane in its workspace. */
|
||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||
/** The thinking mood while a reply is being written. */
|
||||
onBusy?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
title,
|
||||
headerActions,
|
||||
info,
|
||||
onLocal,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
}: PetChatProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// Sent but not in the transcript yet.
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
// The pet's own answers, given before any chat ran.
|
||||
const [local, setLocal] = useState<Line[]>([]);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remember = (ref: PetChatRef | null) => {
|
||||
writePetChat(ref);
|
||||
setChat(ref);
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
let live = true;
|
||||
const check = () =>
|
||||
api.run
|
||||
.get(chat.runId)
|
||||
.then((h) => {
|
||||
if (!live) return;
|
||||
if (h.status !== "running") remember(null);
|
||||
else if (h.sessionId) setSessionId(h.sessionId);
|
||||
})
|
||||
.catch(() => live && remember(null));
|
||||
void check();
|
||||
// Codex learns its session only after a while; Claude's is known at start.
|
||||
const timer = window.setInterval(() => {
|
||||
if (!sessionId) void check();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat?.runId, sessionId]);
|
||||
|
||||
// The conversation, polled while the window is open.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.transcript(sessionId, { limit: 60 })
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages((prev) =>
|
||||
prev.length === r.messages.length &&
|
||||
JSON.stringify(prev[prev.length - 1]) ===
|
||||
JSON.stringify(r.messages[r.messages.length - 1])
|
||||
? prev
|
||||
: r.messages
|
||||
);
|
||||
})
|
||||
.catch(() => undefined); // not started yet: the next poll asks again
|
||||
void load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
const read = () =>
|
||||
setMenu((prev) => {
|
||||
const next = readRunMenu(chat.runId);
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = [...local, ...chatLines(messages)];
|
||||
// The pending line shows until the transcript has it.
|
||||
useEffect(() => {
|
||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||
setPending(null);
|
||||
}, [lines, pending]);
|
||||
const busy = starting || !!pending || isBusy(messages);
|
||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||
|
||||
// Keep the newest in sight.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length, pending, busy, menu]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const prompt = text.trim();
|
||||
if (!prompt || starting) return;
|
||||
setError(null);
|
||||
if (!chat && onLocal) {
|
||||
const reply = onLocal(prompt);
|
||||
if (reply && "handled" in reply) return;
|
||||
if (reply) {
|
||||
const n = Date.now();
|
||||
setLocal((prev) => [
|
||||
...prev,
|
||||
{ key: `lu${n}`, role: "user", text: prompt },
|
||||
{ key: `la${n}`, role: "assistant", text: reply.answer },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPending(prompt);
|
||||
if (chat) {
|
||||
// Typed into the attached pane; it may still be attaching.
|
||||
for (let i = 0; i < SEND_TRIES; i++) {
|
||||
if (submitToRun(chat.runId, prompt)) return;
|
||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||
}
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(t("sagi.chat.notSent"));
|
||||
return;
|
||||
}
|
||||
const tool = petChatTool() ?? "claude";
|
||||
setStarting(true);
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(
|
||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t, onLocal]
|
||||
);
|
||||
|
||||
// New chat: the old one's session is stopped, not left running unseen —
|
||||
// asking first only while it is still writing a reply.
|
||||
const newChat = async () => {
|
||||
if (chat) {
|
||||
if (
|
||||
busy &&
|
||||
!(await confirmDialog({
|
||||
title: t("sagi.chat.endTitle"),
|
||||
message: t("sagi.chat.endBody"),
|
||||
confirmLabel: t("sagi.chat.endConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(chat.runId, { archive: true });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
remember(null);
|
||||
setLocal([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
const text = draft;
|
||||
setDraft("");
|
||||
void send(text);
|
||||
};
|
||||
|
||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||
const empty = lines.length === 0 && !pending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.chat.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
busy
|
||||
? "animate-pulse bg-status-warning"
|
||||
: chat
|
||||
? "bg-status-success"
|
||||
: "bg-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{headerActions}
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => void newChat()}
|
||||
disabled={(!chat && !local.length) || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{info}
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="text-3xl" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{t("sagi.chat.emptyTitle", { name })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((l) =>
|
||||
l.role === "tools" ? (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||
<span className="truncate">{toolSummary(l.names)}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : l.role === "user" ? (
|
||||
<div key={l.key} className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||
{l.text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||
<MarkdownContent text={l.text} dense prose />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{pending && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||
{pending}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{busy && !menu && (
|
||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{starting && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||
</p>
|
||||
)}
|
||||
{chat && !sessionId && !starting && (
|
||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* prompt, or the question the CLI asks */}
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{menu && chat ? (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
<PaneMenuCard
|
||||
menu={menu}
|
||||
onChoose={(i) => {
|
||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onAnswer={(text) => {
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.chat.placeholder", { name })}
|
||||
aria-label={t("sagi.chat.placeholder", { name })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || starting}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||
{chat && (
|
||||
<div className="pet-chat-term" aria-hidden>
|
||||
<TerminalView
|
||||
runId={chat.runId}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={false}
|
||||
// The pane ended: the conversation stays on screen, the next
|
||||
// message starts a new session.
|
||||
onExit={() => {
|
||||
writePetChat(null);
|
||||
setChat(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @file PetPanel.tsx
|
||||
* @description The pet's window — clicking the pet opens it: its name (which
|
||||
* the user can rename), a pat button, and what the pet knows (`info`: live /
|
||||
* waiting / errored, the sessions that need you, Mute, Clear alerts —
|
||||
* `SagiInfo.tsx`). The pet no longer chats: it started a full AI session per
|
||||
* question, a heavy process on a shared server, so the chat was removed
|
||||
* (v2.8.68); this window is where the pet's own care comes next.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export function PetPanel({
|
||||
name,
|
||||
title,
|
||||
headerActions,
|
||||
info,
|
||||
onClose,
|
||||
}: {
|
||||
name: string;
|
||||
/** The name line (a rename button); plain text without it. */
|
||||
title?: ReactNode;
|
||||
/** Buttons before Close (the pat). */
|
||||
headerActions?: ReactNode;
|
||||
/** What the pet knows (`SagiInfo`). */
|
||||
info: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex max-h-[calc(100vh-6rem)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||
</div>
|
||||
{headerActions}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
title={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Vertical only: an auto overflow-y makes x scrollable too, and the
|
||||
status chips' row then showed a horizontal scrollbar. */}
|
||||
<div className="min-w-0 overflow-y-auto overflow-x-hidden">{info}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,8 @@
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
||||
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
||||
* itself is answered there, anything else goes to the AI.
|
||||
* Clicking the pet opens its window (`PetPanel`): its name, a pat, and the
|
||||
* status strip (`SagiInfo`). The pet no longer chats (v2.8.68).
|
||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
@@ -15,10 +14,7 @@
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched question is the chat's — it starts a
|
||||
* session of the default AI tool in the "Sagi" workspace; a default tool the
|
||||
* chat cannot read back starts there and its session view opens
|
||||
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -91,15 +87,12 @@ import { PetNameButton, SagiInfo } from "./SagiInfo";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { petName, sagiPrefs } from "./prefs";
|
||||
import type { SagiSubject } from "./brain";
|
||||
import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { Heart } from "lucide-react";
|
||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||
import { PetPanel } from "./PetPanel";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
@@ -229,7 +222,7 @@ function SagiBody() {
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
// The chat window (PetChat.tsx): the pet's whole UI.
|
||||
// The pet's window (PetPanel.tsx).
|
||||
const [open, setOpen] = useState(false);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
@@ -340,43 +333,6 @@ function SagiBody() {
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
// Asked by the chat before any session runs.
|
||||
const onLocal = useCallback(
|
||||
(query: string): PetLocalReply => {
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return { answer: result.text };
|
||||
}
|
||||
// A question the pet cannot answer: the chat starts a session of the
|
||||
// default AI tool and shows it as chat.
|
||||
if (petChatTool()) return null;
|
||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
brain.setThinking(true);
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const tool = readDefaultTool();
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||
});
|
||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
brain.setBubble(t("sagi.startFailed", { error: msg }));
|
||||
} finally {
|
||||
brain.setThinking(false);
|
||||
}
|
||||
})();
|
||||
return { handled: true };
|
||||
},
|
||||
[brain, navigate, t, name]
|
||||
);
|
||||
|
||||
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||
const onStroke = (e: React.PointerEvent) => {
|
||||
@@ -398,8 +354,6 @@ function SagiBody() {
|
||||
s.dir = dir;
|
||||
};
|
||||
|
||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
@@ -412,11 +366,10 @@ function SagiBody() {
|
||||
return (
|
||||
<>
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
||||
re-attaches the same session. */}
|
||||
it 1:1). */}
|
||||
{open && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
<PetPanel
|
||||
name={name}
|
||||
title={
|
||||
<PetNameButton
|
||||
@@ -449,13 +402,7 @@ function SagiBody() {
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file SagiInfo.tsx
|
||||
* @description What the pet knows, shown inside its chat window (`PetChat`):
|
||||
* @description What the pet knows, shown inside its window (`PetPanel`):
|
||||
* the name you rename by clicking it (`PetNameButton`), and a status strip
|
||||
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
||||
* always in sight, and on expanding, the sessions that need the user, by
|
||||
@@ -118,8 +118,8 @@ export function SagiInfo({
|
||||
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={t("sagi.chat.status")}
|
||||
title={t("sagi.chat.status")}
|
||||
aria-label={t("sagi.panel.status")}
|
||||
title={t("sagi.panel.status")}
|
||||
>
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* @file PetChat.test.ts
|
||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chatLines } from "../PetChat";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
||||
|
||||
describe("chatLines", () => {
|
||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
||||
const lines = chatLines([
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", text: "hmm" },
|
||||
{ type: "tool_use", name: "Read" },
|
||||
],
|
||||
}),
|
||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello!" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
||||
"hi",
|
||||
["Read", "Read"],
|
||||
"hello!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops Claude Code's own injected lines", () => {
|
||||
const lines = chatLines([
|
||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sagi.test.tsx
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B window toggle (the pet's window: name, pat, status strip — the pet no longer chats), and accessibility attributes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useSagiPosition } from "../useSagiPosition";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { WSMessage, Session } from "../../../lib/types";
|
||||
|
||||
function renderSagi() {
|
||||
@@ -55,16 +54,15 @@ describe("Sagi widget", () => {
|
||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the chat on click and answers a local status question there", () => {
|
||||
it("opens its window on click — status, no chat box", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(panel).toBeInTheDocument();
|
||||
|
||||
const input = within(panel).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "status" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
expect(within(panel).getByText("live")).toBeInTheDocument();
|
||||
expect(within(panel).queryByRole("textbox")).toBeNull();
|
||||
expect(within(panel).queryByRole("button", { name: /new chat/i })).toBeNull();
|
||||
fireEvent.click(within(panel).getByRole("button", { name: /close sagi/i }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
||||
@@ -89,7 +87,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the errored session in its bubble and in the chat", () => {
|
||||
it("names the errored session in its bubble and in its window", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
@@ -102,7 +100,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
@@ -122,54 +120,17 @@ describe("Sagi widget", () => {
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("renames the pet from the chat header", () => {
|
||||
it("renames the pet from its window header", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet mochi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands a question it cannot answer to the AI", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
||||
ensure.mockRestore();
|
||||
});
|
||||
|
||||
it("reopens the remembered chat, and New chat stops its session", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.pet.chat",
|
||||
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
|
||||
);
|
||||
const get = vi
|
||||
.spyOn(api.run, "get")
|
||||
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
|
||||
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
|
||||
});
|
||||
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
|
||||
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
|
||||
get.mockRestore();
|
||||
kill.mockRestore();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the chat is open", () => {
|
||||
it("a pat shows its reaction on the pet even while its window is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
@@ -187,26 +148,40 @@ describe("Sagi widget", () => {
|
||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the live count in the chat status", () => {
|
||||
it("reflects the live count in its status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "active"));
|
||||
eventBus.publish(sessionMsg("b", "active"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
// The "live" stat chip shows value 2 next to its label.
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
|
||||
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
|
||||
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/theirs/)).toBeNull();
|
||||
});
|
||||
|
||||
it("respects the enabled preference", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||
renderSagi();
|
||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a tap (no movement) still opens the chat", () => {
|
||||
it("a tap (no movement) still opens its window", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
@@ -214,7 +189,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||
@@ -243,7 +218,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
|
||||
// No position was persisted because no real drag happened.
|
||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -529,13 +529,10 @@
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
||||
/* ════════ the pet's window (PetPanel.tsx) ════════ */
|
||||
|
||||
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
||||
colour: a tint of the accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. Replies sit on the plain surface like
|
||||
the reference's white bubbles; the user's own are accent-solid (white text
|
||||
is only ever on accent-solid). */
|
||||
/* A tint of the theme's accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. */
|
||||
.pet-chat {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -547,61 +544,6 @@
|
||||
.pet-chat-head {
|
||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||
}
|
||||
.pet-chat-reply {
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
||||
}
|
||||
.pet-chat-tools {
|
||||
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
||||
}
|
||||
.pet-chat-foot {
|
||||
background: rgb(var(--surface-0));
|
||||
border-top: 1px solid rgb(var(--border) / 0.6);
|
||||
}
|
||||
/* Three bouncing dots while a reply is written. */
|
||||
.pet-chat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--accent));
|
||||
opacity: 0.5;
|
||||
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
.pet-chat-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.pet-chat-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes pet-chat-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
||||
does not shrink the pane to fit it) and a non-zero box (so the paste
|
||||
registry counts it as on screen). */
|
||||
.pet-chat-term {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 760px;
|
||||
height: 460px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-chat-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* burst of the same event into one count), the pet's one-shot animation
|
||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||
* for the UI shell. It also greets once per browser session and makes an
|
||||
* idle pet fidget now and then.
|
||||
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
|
||||
* (`mine=1` seed, and frames marked `mine: false` skipped).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
|
||||
}, GREET_DELAY_MS);
|
||||
};
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.list({ status: "active", limit: 100, mine: true })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Each user's pet tells only of their own sessions: an admin/analyst
|
||||
// gets every user's, marked `mine: false` when not theirs.
|
||||
if (msg.mine === false) return;
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
* the next option whose label starts with it. Focus returns to the trigger on
|
||||
* every close except Tab.
|
||||
*
|
||||
* ## Search — a combobox once the list is long
|
||||
* With more than `SEARCH_FROM` options (or `searchable`), the popup opens with
|
||||
* a search box on top, focused: typing narrows the list to options whose
|
||||
* label or value contains the text (case-insensitive), ↑/↓ move, Enter picks,
|
||||
* Escape closes. The box is `role="combobox"` controlling the listbox through
|
||||
* `aria-activedescendant`. Options may carry a `group`: a heading is drawn
|
||||
* where the group changes.
|
||||
*
|
||||
* ## Consumers
|
||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||
* enum picker is needed.
|
||||
@@ -73,7 +81,11 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Check, Search } from "lucide-react";
|
||||
|
||||
/** A list longer than this gets a search box (unless `searchable` says otherwise). */
|
||||
export const SEARCH_FROM = 8;
|
||||
|
||||
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
||||
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||
@@ -95,6 +107,8 @@ export interface SelectOption<T extends string> {
|
||||
icon?: ReactNode;
|
||||
/** Draw a rule above this option: it starts a group of its own. */
|
||||
divider?: boolean;
|
||||
/** The group this option belongs to: a heading is drawn where it changes. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
@@ -111,6 +125,10 @@ export interface SelectProps<T extends string> {
|
||||
variant?: "field" | "ghost";
|
||||
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
||||
label?: string;
|
||||
/** Show the search box: default only for lists longer than `SEARCH_FROM`. */
|
||||
searchable?: boolean;
|
||||
/** The search box's name and placeholder (default: "Search…"). */
|
||||
searchLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,15 +142,26 @@ export function Select<T extends string>({
|
||||
disabled,
|
||||
variant = "field",
|
||||
label,
|
||||
searchable,
|
||||
searchLabel,
|
||||
}: SelectProps<T>) {
|
||||
const { t } = useTranslation("common");
|
||||
const ghost = variant === "ghost";
|
||||
const canSearch = searchable ?? options.length > SEARCH_FROM;
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [openUp, setOpenUp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
const typed = useRef({ text: "", at: 0 });
|
||||
// The options shown: all of them, or those the search text matches.
|
||||
const q = canSearch ? query.trim().toLowerCase() : "";
|
||||
const shown = q
|
||||
? options.filter((o) => `${o.label} ${o.value}`.toLowerCase().includes(q))
|
||||
: options;
|
||||
const id = useId();
|
||||
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
||||
|
||||
@@ -150,6 +179,7 @@ export function Select<T extends string>({
|
||||
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
@@ -159,14 +189,14 @@ export function Select<T extends string>({
|
||||
);
|
||||
setOpenUp(below < 288 && above > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
(canSearch ? searchRef.current : listRef.current)?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
||||
}, [open, active]);
|
||||
}, [open, active, q]);
|
||||
|
||||
const close = (refocus = true) => {
|
||||
setOpen(false);
|
||||
@@ -185,8 +215,12 @@ export function Select<T extends string>({
|
||||
}
|
||||
};
|
||||
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const last = options.length - 1;
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
const last = shown.length - 1;
|
||||
// In the search box, keys that edit text stay the box's own.
|
||||
const inSearch = e.currentTarget === searchRef.current;
|
||||
if (inSearch && (e.key === " " || e.key === "Home" || e.key === "End")) return;
|
||||
if (inSearch && e.key === "Enter" && e.nativeEvent.isComposing) return;
|
||||
const move: Record<string, (a: number) => number> = {
|
||||
ArrowDown: (a) => Math.min(last, a + 1),
|
||||
ArrowUp: (a) => Math.max(0, a - 1),
|
||||
@@ -200,7 +234,7 @@ export function Select<T extends string>({
|
||||
setActive(move[e.key]!);
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const opt = options[active];
|
||||
const opt = shown[active];
|
||||
if (opt) choose(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
// Stop here: a Select inside a dialog must not close the dialog too.
|
||||
@@ -209,7 +243,7 @@ export function Select<T extends string>({
|
||||
close();
|
||||
} else if (e.key === "Tab") {
|
||||
close(false);
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
} else if (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
||||
// character cycles through the options that start with it.
|
||||
const now = Date.now();
|
||||
@@ -258,57 +292,100 @@ export function Select<T extends string>({
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} flex max-h-72 flex-col overflow-hidden rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
{options.map((opt, idx) => {
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<div
|
||||
key={opt.value || "__default__"}
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||
} ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||
{canSearch && (
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-b border-border px-2.5 py-1.5">
|
||||
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
<input
|
||||
ref={searchRef}
|
||||
role="combobox"
|
||||
aria-label={searchLabel ?? t("select.search")}
|
||||
aria-expanded
|
||||
aria-controls={`${id}-list`}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={shown[active] ? optionId(active) : undefined}
|
||||
placeholder={searchLabel ?? t("select.search")}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null))
|
||||
setOpen(false);
|
||||
}}
|
||||
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className="min-h-0 flex-1 overflow-auto py-1 outline-none"
|
||||
>
|
||||
{canSearch && shown.length === 0 && (
|
||||
<div className="px-3 py-1.5 text-[11px] text-fg-muted">{t("select.noMatch")}</div>
|
||||
)}
|
||||
{shown.map((opt, idx) => {
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
|
||||
return (
|
||||
<div key={opt.value || "__default__"} role="none">
|
||||
{heading && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="mt-1 border-t border-border px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||
} ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* which the sidebar owns and which also carries the update check), the version
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
|
||||
* OWN sessions even for an admin —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
||||
* user's OWN spend even for an admin — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
@@ -28,7 +29,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
@@ -37,6 +38,12 @@ import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import {
|
||||
HIDDEN_PROFILES_EVENT,
|
||||
isHiddenProfilesKey,
|
||||
readHiddenProfiles,
|
||||
standIn,
|
||||
} from "../lib/hiddenProfilesStorage";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { sessionChangeMatcher } from "../lib/sessionChange";
|
||||
@@ -275,10 +282,44 @@ function QuotaPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/** The profiles hidden in Settings → AI tools → Profiles, kept current. */
|
||||
function useHiddenProfiles(): string[] {
|
||||
const [hidden, setHidden] = useState<string[]>(readHiddenProfiles);
|
||||
useEffect(() => {
|
||||
const again = () => setHidden(readHiddenProfiles());
|
||||
const onStorage = (e: StorageEvent) => isHiddenProfilesKey(e.key) && again();
|
||||
window.addEventListener(HIDDEN_PROFILES_EVENT, again);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(HIDDEN_PROFILES_EVENT, again);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude rows without the hidden profiles. When the live login is one of
|
||||
* them, the first visible profile stands in as the live row — its own usage
|
||||
* on the chip and the panel — so the panel never names the hidden account.
|
||||
*/
|
||||
export function withoutHidden(tools: ToolQuota[], hidden: string[]): ToolQuota[] {
|
||||
if (!hidden.length) return tools;
|
||||
const claude = tools.filter((q) => q.id === "claude");
|
||||
const isLive = (q: ToolQuota) => q.active !== false;
|
||||
const decoy = standIn(claude, isLive, hidden);
|
||||
const keep = (q: ToolQuota) =>
|
||||
q.id !== "claude" || !q.accountUuid || !hidden.includes(q.accountUuid);
|
||||
const shown = tools.filter(keep);
|
||||
if (!decoy) return shown;
|
||||
// The stand-in leads the Claude rows, as the live login does.
|
||||
return [{ ...decoy, active: true }, ...shown.filter((q) => q !== decoy)];
|
||||
}
|
||||
|
||||
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||
function QuotaChips() {
|
||||
const { t } = useTranslation("nav");
|
||||
const [tools, setTools] = useState<ToolQuota[]>([]);
|
||||
const [fetched, setTools] = useState<ToolQuota[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
@@ -302,6 +343,8 @@ function QuotaChips() {
|
||||
);
|
||||
const [refreshing, refresh] = useSpin(() => load(true));
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
const hidden = useHiddenProfiles();
|
||||
const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
<>
|
||||
@@ -388,11 +431,11 @@ export function StatusBar({
|
||||
|
||||
const loadCounts = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 1 })
|
||||
.list({ status: "active", limit: 1, mine: true })
|
||||
.then((r) => setActive(r.total))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 1 })
|
||||
.list({ awaiting: true, limit: 1, mine: true })
|
||||
.then((r) => setNeedYou(r.total))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
@@ -8,18 +8,26 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Check, Info, X } from "lucide-react";
|
||||
import { AlertCircle, AlertTriangle, Check, Info, X } from "lucide-react";
|
||||
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
||||
|
||||
const TONE: Record<ToastItem["kind"], string> = {
|
||||
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
||||
info: "border-border bg-surface-2 text-fg-primary",
|
||||
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
||||
warning: "border-status-warning/50 bg-status-warning/10 text-fg-primary",
|
||||
};
|
||||
|
||||
function Item({ item }: { item: ToastItem }) {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
|
||||
const Icon =
|
||||
item.kind === "error"
|
||||
? AlertCircle
|
||||
: item.kind === "warning"
|
||||
? AlertTriangle
|
||||
: item.kind === "info"
|
||||
? Info
|
||||
: Check;
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
|
||||
@@ -559,6 +559,8 @@ export function WebhookSettings() {
|
||||
headerRows: [],
|
||||
})
|
||||
}
|
||||
// A short, fixed list of providers: no search box.
|
||||
searchable={false}
|
||||
options={providers.map((p) => ({ value: p.type, label: p.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +90,9 @@ import { UserAvatar } from "./users/UserAvatar";
|
||||
const AddLaneModal = lazy(() =>
|
||||
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
|
||||
);
|
||||
const WorkspaceShareModal = lazy(() =>
|
||||
import("./lanes/WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
|
||||
);
|
||||
const DestructiveLaneModal = lazy(() =>
|
||||
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
|
||||
);
|
||||
@@ -116,7 +119,9 @@ export function WorkspaceTree() {
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const { state: auth } = useAuth();
|
||||
const { seesAll, canEditLane } = usePermissions();
|
||||
const { seesAll, canEditLane, ownsLane } = usePermissions();
|
||||
// Sharing a workspace with other users (server mode): the open dialog's lane.
|
||||
const [sharing, setSharing] = useState<Lane | null>(null);
|
||||
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
// Server mode staff see every user's workspaces, so the list says whose.
|
||||
const grouped = auth.mode === "server" && seesAll;
|
||||
@@ -375,6 +380,7 @@ export function WorkspaceTree() {
|
||||
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||
if (action === "rename") setRenamingId(lane.id);
|
||||
else if (action === "share") setSharing(lane);
|
||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||
};
|
||||
@@ -559,7 +565,8 @@ export function WorkspaceTree() {
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={nudge}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
// A workspace shared with this user keeps its owner's name.
|
||||
onRenameStart={() => ownsLane(lane) && setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
@@ -688,6 +695,7 @@ export function WorkspaceTree() {
|
||||
</ContextMenu>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
{sharing && <WorkspaceShareModal lane={sharing} onClose={() => setSharing(null)} />}
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
@@ -27,6 +27,9 @@ const LANES = [
|
||||
];
|
||||
vi.mock("../../lib/api", () => ({
|
||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||
TOKEN_REQUIRED_EVENT: "agenthub:token-required",
|
||||
dashboardToken: () => localStorage.getItem("dashboard_token"),
|
||||
saveDashboardToken: (t: string) => localStorage.setItem("dashboard_token", t),
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
@@ -117,12 +120,42 @@ describe("AuthGate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the app when an older server or a dashboard token answers instead", async () => {
|
||||
for (const err of [
|
||||
{ status: 404 },
|
||||
{ status: 200, code: "EBADRESPONSE" },
|
||||
{ status: 401, code: "EUNAUTHORIZED" },
|
||||
]) {
|
||||
it("asks for the dashboard token when DASHBOARD_TOKEN refuses this browser", async () => {
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), { status: 401, code: "EUNAUTHORIZED" }));
|
||||
const reload = vi.fn();
|
||||
const loc = window.location;
|
||||
// @ts-expect-error test override
|
||||
delete window.location;
|
||||
// @ts-expect-error test override
|
||||
window.location = { ...loc, reload };
|
||||
try {
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
const input = await screen.findByLabelText("Token");
|
||||
expect(screen.queryByText("app")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/token was not accepted/)).not.toBeInTheDocument();
|
||||
fireEvent.change(input, { target: { value: " s3cret " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(localStorage.getItem("dashboard_token")).toBe("s3cret");
|
||||
expect(reload).toHaveBeenCalled();
|
||||
} finally {
|
||||
// @ts-expect-error test override
|
||||
window.location = loc;
|
||||
}
|
||||
});
|
||||
|
||||
it("says a saved token was refused, and asks on any request's 401 too", async () => {
|
||||
localStorage.setItem("dashboard_token", "old");
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("agenthub:token-required"));
|
||||
});
|
||||
expect(await screen.findByText(/token was not accepted/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the app when an older server answers instead", async () => {
|
||||
for (const err of [{ status: 404 }, { status: 200, code: "EBADRESPONSE" }]) {
|
||||
me.mockReset();
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
||||
const { unmount } = render(<AuthGate>app</AuthGate>);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file Tests for PdfView: pdf.js (mocked — jsdom has no canvas) draws one
|
||||
* canvas per page, scripting off, and a PDF that cannot be read says so.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../i18n";
|
||||
|
||||
const getDocument = vi.fn();
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||
GlobalWorkerOptions: { workerSrc: "" },
|
||||
getDocument: (...a: unknown[]) => getDocument(...a),
|
||||
}));
|
||||
|
||||
import { PdfView } from "../PdfView";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
beforeEach(() => {
|
||||
getDocument.mockReset();
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response(new Uint8Array([37, 80, 68, 70]))
|
||||
) as typeof fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("PdfView", () => {
|
||||
it("draws every page on its own canvas, with XFA off", async () => {
|
||||
const render1 = vi.fn(() => ({ promise: Promise.resolve() }));
|
||||
getDocument.mockReturnValue({
|
||||
destroy: vi.fn(),
|
||||
promise: Promise.resolve({
|
||||
numPages: 3,
|
||||
getPage: async () => ({
|
||||
getViewport: ({ scale }: { scale: number }) => ({
|
||||
width: 600 * scale,
|
||||
height: 800 * scale,
|
||||
}),
|
||||
render: render1,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const { container } = render(<PdfView url="/api/artifacts/1/raw?path=a.pdf" title="a.pdf" />);
|
||||
await waitFor(() => expect(container.querySelectorAll("canvas")).toHaveLength(3));
|
||||
expect(render1).toHaveBeenCalledTimes(3);
|
||||
expect(getDocument.mock.calls[0]![0]).toMatchObject({ enableXfa: false });
|
||||
expect(screen.getByLabelText("Page 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when the file cannot be read", async () => {
|
||||
getDocument.mockReturnValue({ destroy: vi.fn(), promise: Promise.reject(new Error("bad")) });
|
||||
render(<PdfView url="/x" title="x.pdf" />);
|
||||
expect(await screen.findByText(/could not be shown/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,61 @@ describe("Select (listbox button)", () => {
|
||||
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||||
vi.stubGlobal("innerHeight", 2000);
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
||||
expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("turns a long list into a combobox: type to narrow, Enter picks, groups keep headings", async () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "", label: "Switch branch…" },
|
||||
...["develop", "main", "feat/campaign-pause", "fix/campaign-step", "chore/a", "chore/b"].map(
|
||||
(v) => ({ value: v, label: v })
|
||||
),
|
||||
...["origin/feat/campaign-statistic", "origin/x", "origin/y"].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
group: "On the remote",
|
||||
})),
|
||||
];
|
||||
render(<Select value="" onChange={onChange} options={options} label="Switch branch…" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
|
||||
const search = screen.getByRole("combobox", { name: "Search…" });
|
||||
expect(search).toHaveFocus();
|
||||
expect(screen.getByText("On the remote")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(search, { target: { value: "CAMPAIGN" } });
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
"feat/campaign-pause",
|
||||
"fix/campaign-step",
|
||||
"origin/feat/campaign-statistic",
|
||||
]);
|
||||
fireEvent.keyDown(search, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(search, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith("fix/campaign-step");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzz" } });
|
||||
expect(screen.getByText("No match")).toBeInTheDocument();
|
||||
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a short list a plain listbox (no search box)", () => {
|
||||
render(
|
||||
<Select
|
||||
value="a"
|
||||
onChange={vi.fn()}
|
||||
options={[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
]}
|
||||
label="Pick"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pick" }));
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
expect(screen.getByRole("listbox")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ vi.mock("../../lib/api", () => ({
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { StatusBar } from "../StatusBar";
|
||||
import { StatusBar, withoutHidden } from "../StatusBar";
|
||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||
|
||||
const at = (url: string, ui: React.JSX.Element) =>
|
||||
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
|
||||
);
|
||||
await screen.findByText("1 need you");
|
||||
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
||||
// The user's OWN sessions, even for an admin who sees every user's rows.
|
||||
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
|
||||
expect(p).toMatchObject({ mine: true });
|
||||
await user.click(conn);
|
||||
expect(onConnectionClick).toHaveBeenCalled();
|
||||
// Polite live region: the connection and who is waiting, nothing that ticks.
|
||||
@@ -304,3 +307,35 @@ describe("StatusBar", () => {
|
||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutHidden", () => {
|
||||
const row = (accountUuid: string, active: boolean, pct: number) => ({
|
||||
id: "claude" as const,
|
||||
active,
|
||||
accountUuid,
|
||||
account: `${accountUuid}@x.y`,
|
||||
windows: [{ label: "5h", usedPercent: pct, resetsAt: null }],
|
||||
});
|
||||
const codex = { id: "codex" as const, active: true, windows: [] };
|
||||
|
||||
it("leaves the list alone when nothing is hidden", () => {
|
||||
const list = [row("u1", true, 82), row("u2", false, 19), codex];
|
||||
expect(withoutHidden(list, [])).toBe(list);
|
||||
});
|
||||
|
||||
it("drops a hidden saved profile", () => {
|
||||
const out = withoutHidden([row("u1", true, 82), row("u2", false, 19), codex], ["u2"]);
|
||||
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u1", "codex"]);
|
||||
});
|
||||
|
||||
it("puts the first visible profile, with its own usage, in place of a hidden live login", () => {
|
||||
const out = withoutHidden(
|
||||
[row("u1", true, 82), row("u2", false, 19), row("u3", false, 0), codex],
|
||||
["u1"]
|
||||
);
|
||||
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u2", "u3", "codex"]);
|
||||
expect(out[0]?.active).toBe(true);
|
||||
expect(out[0]?.windows[0]?.usedPercent).toBe(19);
|
||||
expect(out[1]?.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file ServerSettings.tsx
|
||||
* @description Administration → **Server settings** (server mode, an admin
|
||||
* only): every setting that acts on the server for everyone, moved out of
|
||||
* Settings so a user's Settings holds only their own things. Laptop
|
||||
* connections and every user's enrolled machines, members' test runs (S3),
|
||||
* the shared Oh My Pi login (S3), Artifacts Hub installs, the host's git
|
||||
* logins, scheduled reports, start at login / the icon / Quit, model pricing
|
||||
* and the data tools. Each block keeps its own component (each still checks
|
||||
* the role and hides itself where it does not apply); in local mode these
|
||||
* stay in Settings. A `#anchor` in the URL scrolls to its block.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RemotePolicy } from "../remote/RemotePolicy";
|
||||
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||
import { MemberPolicySettings } from "../settings/MemberPolicySettings";
|
||||
import { OmpSharingSettings } from "../settings/OmpSharingSettings";
|
||||
import { ArtifactsHubSettings } from "../settings/ArtifactsHubSettings";
|
||||
import { ReportSettings } from "../settings/ReportSettings";
|
||||
import { AutostartSettings } from "../settings/AutostartSettings";
|
||||
import { AllPricingSettings } from "../settings/PricingSettings";
|
||||
import { SettingsSection } from "../settings/SettingsRow";
|
||||
import { GitCredentialsConfig } from "../../pages/GitCredentialsConfig";
|
||||
import { Settings } from "../../pages/Settings";
|
||||
|
||||
function Block({ id, children }: { id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { hash } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) return;
|
||||
const t1 = window.setTimeout(() => {
|
||||
document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView({
|
||||
block: "start",
|
||||
});
|
||||
}, 50);
|
||||
return () => window.clearTimeout(t1);
|
||||
}, [hash]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6" data-testid="server-settings">
|
||||
<Block id="company-hub">
|
||||
<SettingsSection
|
||||
id="company-hub-admin"
|
||||
title={t("companyHub.adminTitle")}
|
||||
description={t("companyHub.adminDescription")}
|
||||
>
|
||||
<RemotePolicy />
|
||||
<RemoteDevices canRevoke />
|
||||
</SettingsSection>
|
||||
</Block>
|
||||
{/* These carry their own anchors (member-policy, omp-sharing, artifacts-hub). */}
|
||||
<MemberPolicySettings />
|
||||
<OmpSharingSettings />
|
||||
<ArtifactsHubSettings />
|
||||
<Block id="git-credentials">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
||||
<GitCredentialsConfig />
|
||||
</Block>
|
||||
<Block id="reports">
|
||||
<ReportSettings />
|
||||
</Block>
|
||||
<AutostartSettings />
|
||||
<Block id="pricing">
|
||||
<AllPricingSettings />
|
||||
</Block>
|
||||
{/* Its own anchor: #data. */}
|
||||
<Settings section="data" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file ServerSettings.test.tsx
|
||||
* @description Administration → Server settings: every server-wide block is
|
||||
* there (laptop connections + every machine, members' tests, omp sharing,
|
||||
* Artifacts installs, git logins, reports, start at login, pricing, data),
|
||||
* and an admin turns laptop connections on after one confirm (no password).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { 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";
|
||||
|
||||
const stub = vi.hoisted(() => (id: string) => () => <div data-testid={id} />);
|
||||
vi.mock("../../settings/MemberPolicySettings", () => ({
|
||||
MemberPolicySettings: stub("member-policy"),
|
||||
}));
|
||||
vi.mock("../../settings/OmpSharingSettings", () => ({ OmpSharingSettings: stub("omp-sharing") }));
|
||||
vi.mock("../../settings/ArtifactsHubSettings", () => ({
|
||||
ArtifactsHubSettings: stub("artifacts-hub"),
|
||||
}));
|
||||
vi.mock("../../settings/ReportSettings", () => ({ ReportSettings: stub("reports") }));
|
||||
vi.mock("../../settings/AutostartSettings", () => ({ AutostartSettings: stub("autostart") }));
|
||||
vi.mock("../../settings/PricingSettings", () => ({ AllPricingSettings: stub("pricing") }));
|
||||
vi.mock("../../../pages/GitCredentialsConfig", () => ({ GitCredentialsConfig: stub("git-creds") }));
|
||||
vi.mock("../../../pages/Settings", () => ({ Settings: stub("data") }));
|
||||
vi.mock("../../remote/RemoteDevices", () => ({ RemoteDevices: stub("remote-devices") }));
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
remoteHub: { policy: vi.fn(), setPolicy: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
});
|
||||
|
||||
import { ServerSettings } from "../ServerSettings";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
const show = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ServerSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("ServerSettings", () => {
|
||||
it("holds every server-wide block", async () => {
|
||||
api.remoteHub.policy.mockResolvedValue({ allowDevices: false, changedAt: null });
|
||||
show();
|
||||
for (const id of [
|
||||
"remote-devices",
|
||||
"member-policy",
|
||||
"omp-sharing",
|
||||
"artifacts-hub",
|
||||
"git-creds",
|
||||
"reports",
|
||||
"autostart",
|
||||
"pricing",
|
||||
"data",
|
||||
]) {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
}
|
||||
expect(await screen.findByRole("switch", { name: /laptop/i })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false"
|
||||
);
|
||||
});
|
||||
|
||||
it("turns laptop connections on after a confirm, with no password", async () => {
|
||||
api.remoteHub.policy.mockResolvedValue({ allowDevices: false, changedAt: null });
|
||||
api.remoteHub.setPolicy.mockResolvedValue({
|
||||
allowDevices: true,
|
||||
changedAt: "2026-11-09T04:00:00.000Z",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
show();
|
||||
const sw = await screen.findByRole("switch", { name: /laptop/i });
|
||||
await user.click(sw);
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0]).toMatchObject({ destructive: true });
|
||||
await waitFor(() => expect(api.remoteHub.setPolicy).toHaveBeenCalledWith(true));
|
||||
expect(api.remoteHub.setPolicy.mock.calls[0]).toHaveLength(1);
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file MarkdownContent.tsx
|
||||
* @description Lightweight markdown renderer for conversation messages. Supports the
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists (nested by indent; a
|
||||
* numbered list split by a blank line keeps counting from its own numbers),
|
||||
* task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
@@ -68,7 +70,7 @@ import { onLinkClick } from "../../lib/openInPreview";
|
||||
type Block =
|
||||
| { kind: "code"; lang: string; code: string }
|
||||
| { kind: "heading"; level: number; text: string }
|
||||
| { kind: "list"; ordered: boolean; items: string[] }
|
||||
| { kind: "list"; ordered: boolean; start: number; items: string[] }
|
||||
| { kind: "quote"; text: string }
|
||||
| { kind: "hr" }
|
||||
| {
|
||||
@@ -196,27 +198,35 @@ function parseBlocks(src: string): Block[] {
|
||||
if (ulMatch || olMatch) {
|
||||
const ordered = !!olMatch;
|
||||
const itemRe = ordered ? OL_RE : UL_RE;
|
||||
const base = (olMatch ?? ulMatch)![1]!.length;
|
||||
// The number the list starts at: a list broken by a blank line or a
|
||||
// stray block still counts on from where the text says ("2.", "3.").
|
||||
const start = olMatch ? Number(olMatch[2]) || 1 : 1;
|
||||
const items: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const m = lines[i]!.match(itemRe);
|
||||
if (m) {
|
||||
if (m && m[1]!.length <= base) {
|
||||
items.push(m[3]!);
|
||||
i++;
|
||||
// The item's own lines: indented text, and a list nested under it
|
||||
// (indented deeper than this list's marker) — kept with its
|
||||
// relative indent so the item renders it as a sub-list.
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i]!.trim() !== "" &&
|
||||
!lines[i]!.match(UL_RE) &&
|
||||
!lines[i]!.match(OL_RE) &&
|
||||
/^\s+\S/.test(lines[i]!)
|
||||
/^\s+\S/.test(lines[i]!) &&
|
||||
(lines[i]!.match(/^\s*/)![0].length > base ||
|
||||
(!lines[i]!.match(UL_RE) && !lines[i]!.match(OL_RE)))
|
||||
) {
|
||||
items[items.length - 1] += "\n" + lines[i]!.trim();
|
||||
const nested = UL_RE.test(lines[i]!) || OL_RE.test(lines[i]!);
|
||||
items[items.length - 1] += "\n" + (nested ? lines[i]! : lines[i]!.trim());
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
blocks.push({ kind: "list", ordered, items });
|
||||
blocks.push({ kind: "list", ordered, start, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -524,8 +534,27 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render a single list item, handling [ ] / [x] task list prefixes. */
|
||||
/** Render a single list item: its text, then any list nested under it. */
|
||||
function renderListItem(item: string, key: string): React.ReactNode {
|
||||
const lines = item.split("\n");
|
||||
const sub = lines.findIndex((l, n) => n > 0 && (UL_RE.test(l) || OL_RE.test(l)));
|
||||
if (sub > 0) {
|
||||
const rest = lines.slice(sub);
|
||||
const indent = Math.min(...rest.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
|
||||
return (
|
||||
<>
|
||||
{renderItemText(lines.slice(0, sub).join("\n"), key)}
|
||||
<div className="mt-1">
|
||||
<MarkdownContent text={rest.map((l) => l.slice(indent)).join("\n")} dense />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return renderItemText(item, key);
|
||||
}
|
||||
|
||||
/** An item's own text, handling [ ] / [x] task list prefixes. */
|
||||
function renderItemText(item: string, key: string): React.ReactNode {
|
||||
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
|
||||
if (taskMatch) {
|
||||
const checked = taskMatch[1]!.toLowerCase() === "x";
|
||||
@@ -594,6 +623,7 @@ export function MarkdownContent({ text, dense = false, prose = false }: Markdown
|
||||
return (
|
||||
<ol
|
||||
key={idx}
|
||||
start={b.start !== 1 ? b.start : undefined}
|
||||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, withToken } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
/** The icon of a transcript row's sender. A JSONL `type:"user"` line is not
|
||||
@@ -1656,7 +1656,7 @@ function DropFile({ path }: { path: string }) {
|
||||
function DropImage({ name }: { name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gone, setGone] = useState(false);
|
||||
const src = `/api/run/drop/${encodeURIComponent(name)}`;
|
||||
const src = withToken(`/api/run/drop/${encodeURIComponent(name)}`);
|
||||
// Drops are pruned after a day: the name is all that is left to show.
|
||||
if (gone)
|
||||
return (
|
||||
|
||||
@@ -89,6 +89,37 @@ describe("<MarkdownContent />", () => {
|
||||
expect(container.querySelectorAll("ol li")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps one numbered list when its items carry indented bullets", () => {
|
||||
const text = [
|
||||
"Có 4 điểm:",
|
||||
"",
|
||||
"1. **yt-dlp**",
|
||||
" - Hướng dẫn",
|
||||
" - YouTube",
|
||||
"2. **Nút Setup**",
|
||||
" - site-packages",
|
||||
"3. **Log khởi động**",
|
||||
" Có thể bỏ dòng này.",
|
||||
"4. **Nên kiểm tra**",
|
||||
].join("\n");
|
||||
const { container } = render(<MarkdownContent text={text} />);
|
||||
const top = container.querySelectorAll(":scope > div > ol");
|
||||
expect(top).toHaveLength(1);
|
||||
expect(top[0]!.querySelectorAll(":scope > li")).toHaveLength(4);
|
||||
expect(top[0]!.querySelectorAll("ul li")).toHaveLength(3);
|
||||
expect(screen.getByText(/Có thể bỏ dòng này/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("continues the numbering of a list split by a blank line or a block", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent text={"1. first\n\n```\ncode\n```\n\n2. second\n3. third"} />
|
||||
);
|
||||
const lists = container.querySelectorAll("ol");
|
||||
expect(lists).toHaveLength(2);
|
||||
expect(lists[0]!.getAttribute("start")).toBeNull();
|
||||
expect(lists[1]!.getAttribute("start")).toBe("2");
|
||||
});
|
||||
|
||||
it("renders blockquotes", () => {
|
||||
const { container } = render(<MarkdownContent text={"> a quote"} />);
|
||||
expect(container.querySelector("blockquote")).not.toBeNull();
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||
* busiest processes with who each counts for (history, CPU threads and
|
||||
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
|
||||
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
|
||||
* workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||
* Administration users table shares for each home's share of the homes disk.
|
||||
* Read-only.
|
||||
@@ -38,14 +40,17 @@ import {
|
||||
type HostHistoryRange,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { Tip } from "../Tip";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_POLL_MS = 30000;
|
||||
const USERS_POLL_MS = 60000;
|
||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||
|
||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||
// The user tile whose busiest processes are shown.
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
// Each account's role and spend for the tiles' hover card. `GET /api/users`
|
||||
// is admin/analyst only, so a developer gets no card.
|
||||
const { seesAll, local } = usePermissions();
|
||||
const { t: tu } = useTranslation("users");
|
||||
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
|
||||
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
|
||||
useEffect(() => {
|
||||
if (!seesAll || local || !hasPeople) return;
|
||||
const load = () =>
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
|
||||
.catch(() => {});
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, USERS_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [seesAll, local, hasPeople]);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
() =>
|
||||
@@ -365,6 +389,29 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
if (key === "system") return t("host.systemGroup");
|
||||
return key;
|
||||
};
|
||||
const userTip = (g: HostUsageGroup) => {
|
||||
const u = g.userId != null ? accounts.get(g.userId) : undefined;
|
||||
// No account row (a developer, or not read yet): no card at all.
|
||||
if (!u) return "";
|
||||
const lines = [groupName(g)];
|
||||
{
|
||||
lines.push(
|
||||
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
|
||||
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
|
||||
);
|
||||
if (u.today)
|
||||
lines.push(
|
||||
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
|
||||
);
|
||||
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
|
||||
lines.push(
|
||||
t("host.userTipLastLogin", {
|
||||
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
|
||||
})
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
@@ -676,26 +723,30 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
const isOpen = open === g.key;
|
||||
return (
|
||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
{/* The hover card sits on the name row only: over the whole
|
||||
tile it stacked on the CPU bar's own help tip. */}
|
||||
<Tip raw={userTip(g)} block>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* rate and each network interface, the CPU / memory history with its range
|
||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||
* counts for, the processes of no user in a short list of their own, and
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click);
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click; on
|
||||
* hover, for staff, their role and $ spent today);
|
||||
* off Linux the cards it can never fill are left out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -69,8 +70,10 @@ const stats: HostStats = {
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null, list: vi.fn() },
|
||||
}));
|
||||
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
@@ -105,6 +108,7 @@ const full: HostStats = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
|
||||
api.host.stats.mockResolvedValue(full);
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
@@ -182,6 +186,50 @@ describe("HostHealth", () => {
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the role and today's spend when a user tile is hovered", async () => {
|
||||
Object.assign(perms, { local: false });
|
||||
api.users.list.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
workspaces: 2,
|
||||
sessions: 5,
|
||||
today: { cost: 1.5, tokens: 12000 },
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
lastLoginAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
// The card exists only once the users list has arrived.
|
||||
const nameRow = () => within(tile).getByRole("button").parentElement!;
|
||||
const tip = await vi.waitFor(() => {
|
||||
fireEvent.mouseEnter(nameRow());
|
||||
return screen.getByText(/Role: Developer/);
|
||||
});
|
||||
expect(tip.textContent).not.toContain("Login");
|
||||
expect(tip.textContent).toMatch(/Today: \$1\.5/);
|
||||
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
|
||||
// Only the name row carries it: the CPU bar keeps its own help tip alone.
|
||||
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
|
||||
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
|
||||
expect(screen.queryByText(/Role: Developer/)).toBeNull();
|
||||
});
|
||||
|
||||
it("gives a developer no hover card, without reading the users list", async () => {
|
||||
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
|
||||
expect(screen.queryByText(/Today:/)).toBeNull();
|
||||
expect(api.users.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||
api.host.stats.mockResolvedValue({
|
||||
...stats,
|
||||
|
||||
@@ -57,6 +57,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
import { restoreShelf } from "./LaneShelves";
|
||||
import { BranchesButton } from "./git/BranchesButton";
|
||||
import { Select } from "../Select";
|
||||
|
||||
type Action =
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
@@ -526,33 +527,32 @@ export function LaneGitBar({
|
||||
|
||||
{switching && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
||||
>
|
||||
<option value="">{t("gitBar.switchLabel")}</option>
|
||||
{(branches || []).map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
return (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{remoteBranches.length > 0 && (
|
||||
<optgroup label={t("gitBar.remoteBranches")}>
|
||||
{remoteBranches.map((b) => (
|
||||
<option key={`remote:${b}`} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{/* A combobox once the list is long (Select's search box, #88):
|
||||
type to narrow the local and remote branches. */}
|
||||
<div className="min-w-[8rem] flex-1 font-mono">
|
||||
<Select
|
||||
label={t("gitBar.switchLabel")}
|
||||
searchLabel={t("gitBar.branchSearch")}
|
||||
searchable
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={setPicked}
|
||||
options={[
|
||||
{ value: "", label: t("gitBar.switchLabel") },
|
||||
...(branches || []).map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
const note =
|
||||
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
|
||||
return { value: b, label: `${b}${note}` };
|
||||
}),
|
||||
...remoteBranches.map((b) => ({
|
||||
value: b,
|
||||
label: b,
|
||||
group: t("gitBar.remoteBranches"),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
busy={busy === "switch"}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function SessionMenu({
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onPin,
|
||||
onShare,
|
||||
}: MenuPoint & {
|
||||
laneId: number;
|
||||
session: Session | null;
|
||||
@@ -42,6 +43,8 @@ export function SessionMenu({
|
||||
onUnarchive?: () => void;
|
||||
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||
onPin?: () => void;
|
||||
/** Share this session on its own (server mode, the workspace's owner). */
|
||||
onShare?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -81,6 +84,11 @@ export function SessionMenu({
|
||||
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onShare && session && (
|
||||
<MenuItem testId="session-menu-share" onSelect={pick(onShare)}>
|
||||
{t("sessionMenu.share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* workspace home's right column (sticky: it follows the home's scroll) and,
|
||||
* `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||
@@ -157,7 +158,13 @@ function ContextBody({
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
||||
// On the home it follows the scroll (sticky in the home's scroller),
|
||||
// scrolling itself — without a bar — when taller than the window.
|
||||
className={
|
||||
compact
|
||||
? ""
|
||||
: "card no-scrollbar sticky top-2 max-h-[calc(100vh-7rem)] w-80 shrink-0 self-start overflow-y-auto p-0"
|
||||
}
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
|
||||
@@ -57,6 +57,8 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
import { WorkspaceShareModal } from "./WorkspaceShareModal";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { workspaceName } from "./WorkspaceRow";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { menuPoint, useContextMenu } from "../ContextMenu";
|
||||
@@ -118,6 +120,7 @@ function HomeBody({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const { ownsLane } = usePermissions();
|
||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||
const [tool, setTool] = useState(lane.tool);
|
||||
const entry = aiTools.byId(tool);
|
||||
@@ -148,7 +151,7 @@ function HomeBody({
|
||||
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||
const profile = proxyCapable
|
||||
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||||
? (proxies?.find((r) => r.name === proxy && !r.builtin && !r.company) ?? null)
|
||||
: null;
|
||||
const proxyModels: ModelChoice[] | undefined = profile
|
||||
? [
|
||||
@@ -195,6 +198,7 @@ function HomeBody({
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
|
||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||
@@ -270,10 +274,17 @@ function HomeBody({
|
||||
};
|
||||
|
||||
const text = promptWithImages(prompt, images, claude);
|
||||
// A workspace shared with this user: a session they start runs on their
|
||||
// own account and is billed to them (the server answers ENOCOLLABACCOUNT
|
||||
// when they have none).
|
||||
const collab = !ownsLane(lane);
|
||||
// Only a session of it is shared with them (phase 3): no new session here —
|
||||
// they open that session and send it messages (the server answers ESESSIONSHARE).
|
||||
const sessionOnly = lane.access === "session";
|
||||
const start = async () => {
|
||||
// Enter reaches here past the disabled button: the same guards again.
|
||||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||
if (startingRef.current || uploading || !text) return;
|
||||
if (sessionOnly || startingRef.current || uploading || !text) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
@@ -529,7 +540,7 @@ function HomeBody({
|
||||
type="button"
|
||||
data-testid="workspace-home-start"
|
||||
onClick={() => void start()}
|
||||
disabled={starting || uploading || !text}
|
||||
disabled={sessionOnly || starting || uploading || !text}
|
||||
aria-label={t("home.start")}
|
||||
title={t("home.start")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
@@ -539,6 +550,11 @@ function HomeBody({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{collab && (
|
||||
<p data-testid="workspace-home-collab" className="mt-2 text-xs text-fg-muted">
|
||||
{t(sessionOnly ? "shared.sessionOnly" : "shared.ownAccount")}
|
||||
</p>
|
||||
)}
|
||||
{/* The session's quieter choices, under the box like Claude Desktop's. */}
|
||||
<div
|
||||
data-testid="workspace-home-options"
|
||||
@@ -787,9 +803,16 @@ function HomeBody({
|
||||
x={menuAt.x}
|
||||
y={menuAt.y}
|
||||
onClose={() => setMenuAt(null)}
|
||||
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
|
||||
onAction={(a) =>
|
||||
a === "rename"
|
||||
? setTitle(lane.title || "")
|
||||
: a === "share"
|
||||
? setSharing(true)
|
||||
: onMenuAction(a)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{sharing && <WorkspaceShareModal lane={lane} onClose={() => setSharing(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
||||
* 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
|
||||
* Add modal prefilled), Copy path, Share (server mode: `WorkspaceShareModal`),
|
||||
* and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* 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,
|
||||
* what the lane's kind or tool makes meaningless, and the owner-only items
|
||||
* (Rename, Share, Reset, Remove) on a workspace shared with this user. The shell (position,
|
||||
* Escape / outside-click close) is `ContextMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -17,8 +19,9 @@ import { Check } from "lucide-react";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "share" | "reset" | "remove";
|
||||
|
||||
export function WorkspaceRowMenu({
|
||||
lane,
|
||||
@@ -43,9 +46,11 @@ export function WorkspaceRowMenu({
|
||||
onGroup?: (target: string | null | "new") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const { local, ownsLane } = usePermissions();
|
||||
const owner = ownsLane(lane);
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
||||
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
|
||||
{owner && <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>}
|
||||
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
@@ -55,6 +60,11 @@ export function WorkspaceRowMenu({
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
{owner && !local && (
|
||||
<MenuItem testId="workspace-menu-share" onSelect={() => onAction("share")}>
|
||||
{t("menu.share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onGroup && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
@@ -84,15 +94,19 @@ export function WorkspaceRowMenu({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
{t("menu.reset")}
|
||||
</MenuItem>
|
||||
{owner && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
{t("menu.reset")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||
{t("menu.remove")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||
{t("menu.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
@@ -46,6 +46,11 @@ import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||
import { useContextMenu } from "../ContextMenu";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
const WorkspaceShareModal = lazy(() =>
|
||||
import("./WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
|
||||
);
|
||||
|
||||
export const SESSION_LIMIT = 6;
|
||||
|
||||
@@ -256,6 +261,9 @@ export function WorkspaceSessionList({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctx = useContextMenu<RunHandle>();
|
||||
const sessionCtx = useContextMenu<Session>();
|
||||
// Share one session (server mode, the workspace's owner or an admin).
|
||||
const { local, ownsLane } = usePermissions();
|
||||
const [sharing, setSharing] = useState<Session | null>(null);
|
||||
// Rows shown; See all raises it to the whole list.
|
||||
const [limit, setLimit] = useState(SESSION_LIMIT);
|
||||
|
||||
@@ -637,8 +645,18 @@ export function WorkspaceSessionList({
|
||||
onPin={() =>
|
||||
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
||||
}
|
||||
onShare={!local && ownsLane(lane) ? () => setSharing(sessionCtx.menu!.item) : undefined}
|
||||
/>
|
||||
)}
|
||||
{sharing && (
|
||||
<Suspense fallback={null}>
|
||||
<WorkspaceShareModal
|
||||
lane={lane}
|
||||
session={{ id: sharing.id, name: sharing.name }}
|
||||
onClose={() => setSharing(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file WorkspaceShareModal — share a workspace, or one of its sessions
|
||||
* (`session`), with other users (server mode, docs/plans/workspace-sharing/
|
||||
* phases 1 and 3). Ticks people from `GET /api/artifacts/people` (names only)
|
||||
* and saves the whole list through `PUT /api/lanes/:id/shares` or
|
||||
* `PUT /api/lanes/:id/sessions/:sid/shares`. It says plainly what sharing means on this
|
||||
* server: the people it is shared with work as the owner's account and can
|
||||
* read everything in the owner's home. Only the owner or an admin opens it —
|
||||
* the server refuses anyone else (`ENOTOWNER`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
type Person = { id: number; username: string; name: string; disabled?: boolean };
|
||||
|
||||
export function WorkspaceShareModal({
|
||||
lane,
|
||||
session,
|
||||
onClose,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Share this one session instead of the whole workspace. */
|
||||
session?: { id: string; name?: string | null };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
const [people, setPeople] = useState<Person[] | null>(null);
|
||||
const [initial, setInitial] = useState<Set<number>>(new Set());
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const sid = session?.id;
|
||||
Promise.all([
|
||||
api.artifacts.people(),
|
||||
sid ? api.lanes.sessionShares(lane.id, sid) : api.lanes.shares(lane.id),
|
||||
])
|
||||
.then(([p, s]) => {
|
||||
if (!alive) return;
|
||||
// Never the owner: a workspace is not shared with them. Someone it is
|
||||
// already shared with but who was disabled since is not in `people`:
|
||||
// listed anyway, so they can be taken off.
|
||||
const known = new Set(p.people.map((x) => x.id));
|
||||
setPeople([
|
||||
...p.people.filter((x) => x.id !== lane.owner_id),
|
||||
...s.shares.filter((x) => !known.has(x.id)).map((x) => ({ ...x, disabled: true })),
|
||||
]);
|
||||
const ids = new Set(s.shares.map((x) => x.id));
|
||||
setInitial(ids);
|
||||
setPicked(new Set(ids));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!alive) return;
|
||||
toast.error(err);
|
||||
setPeople([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lane.id, lane.owner_id, session?.id]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (people ?? []).filter(
|
||||
(p) => !q || p.name.toLowerCase().includes(q) || p.username.toLowerCase().includes(q)
|
||||
);
|
||||
}, [people, query]);
|
||||
const added = [...picked].filter((id) => !initial.has(id));
|
||||
const changed = added.length > 0 || [...initial].some((id) => !picked.has(id));
|
||||
const name = session ? session.name || session.id : lane.title || lane.cwd;
|
||||
|
||||
const save = async () => {
|
||||
if (added.length > 0) {
|
||||
const names = (people ?? []).filter((p) => added.includes(p.id)).map((p) => p.name);
|
||||
const ok = await confirmDialog({
|
||||
title: t("share.confirmTitle", { name }),
|
||||
message: t("share.confirmBody", { people: names.join(", ") }),
|
||||
confirmLabel: t("share.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = session
|
||||
? await api.lanes.setSessionShares(lane.id, session.id, [...picked])
|
||||
: await api.lanes.setShares(lane.id, [...picked]);
|
||||
toast.success(
|
||||
r.shares.length ? t("share.saved", { count: r.shares.length }) : t("share.savedNone")
|
||||
);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workspace-share-title"
|
||||
data-testid="workspace-share-modal"
|
||||
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="workspace-share-title" className="text-base font-semibold text-fg-primary">
|
||||
{t(session ? "share.sessionTitle" : "share.title")}
|
||||
</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-fg-muted" title={lane.cwd}>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("share.close")}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||
<p className="text-xs text-fg-secondary">
|
||||
{t(session ? "share.sessionWhat" : "share.what")}
|
||||
</p>
|
||||
<div
|
||||
data-testid="workspace-share-warning"
|
||||
className="flex gap-2 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("share.warning")}</span>
|
||||
</div>
|
||||
{people === null ? (
|
||||
<p className="text-xs text-fg-muted">{t("share.loading")}</p>
|
||||
) : people.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("share.nobody")}</p>
|
||||
) : (
|
||||
<>
|
||||
{people.length > 8 && (
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("share.search")}
|
||||
aria-label={t("share.search")}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
)}
|
||||
<ul className="space-y-1" data-testid="workspace-share-people">
|
||||
{shown.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Checkbox
|
||||
checked={picked.has(p.id)}
|
||||
onChange={(v) => toggle(p.id, v)}
|
||||
disabled={busy}
|
||||
label={
|
||||
<span>
|
||||
{p.name}
|
||||
{p.name !== p.username && (
|
||||
<span className="ml-1.5 text-fg-muted">@{p.username}</span>
|
||||
)}
|
||||
{p.disabled && (
|
||||
<span className="ml-1.5 text-fg-muted">{t("share.disabled")}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("share.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
data-testid="workspace-share-save"
|
||||
onClick={() => void save()}
|
||||
disabled={busy || !changed || people === null}
|
||||
>
|
||||
{busy ? t("share.saving") : t("share.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,21 +171,23 @@ beforeEach(() => {
|
||||
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
||||
vi.mocked(h.pick).mockReset();
|
||||
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
commits: shas.map((sha) => ({
|
||||
sha,
|
||||
short: sha.slice(0, 7),
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
})),
|
||||
branch: "feat/thing",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(h.pickPlan)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
commits: shas.map((sha) => ({
|
||||
sha,
|
||||
short: sha.slice(0, 7),
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
})),
|
||||
branch: "feat/thing",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(h.fileHistory)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -538,7 +540,8 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTitle("only-on-thing.ts");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
|
||||
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||
await user.click(screen.getByRole("option", { name: "other" }));
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
@@ -1344,9 +1347,7 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
|
||||
"a".repeat(40),
|
||||
])
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", ["a".repeat(40)])
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,13 +13,22 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
/** Picks `name` in the branch switcher — a Select combobox (#88). */
|
||||
async function pickBranch(user: ReturnType<typeof userEvent.setup>, name: string) {
|
||||
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await user.click(
|
||||
within(screen.getByRole("listbox")).getByRole("option", { name: new RegExp(`^${esc}( —|$)`) })
|
||||
);
|
||||
}
|
||||
|
||||
// A merge asks through the themed confirm (its host lives in App): yes.
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -210,7 +219,7 @@ describe("LaneGitBar", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
// Nothing is sent before the user picks what happens to the changes.
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
@@ -235,7 +244,7 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
||||
@@ -258,7 +267,7 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||
await waitFor(() =>
|
||||
@@ -291,7 +300,7 @@ describe("LaneGitBar", () => {
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
|
||||
@@ -319,8 +328,8 @@ describe("LaneGitBar", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await user.selectOptions(select, "development");
|
||||
await screen.findByRole("button", { name: "Switch branch…" });
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -332,11 +341,42 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("opens the branch switcher as a combobox: type to narrow, Enter picks (#88)", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
|
||||
remotes: ["origin/feat/campaign-statistic", "origin/feature/thienpt/unit_test"],
|
||||
current: "development",
|
||||
worktrees: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const trigger = await screen.findByRole("button", { name: "Switch branch…" });
|
||||
await user.click(trigger);
|
||||
const search = screen.getByRole("combobox", { name: "Search branches…" });
|
||||
expect(search).toHaveFocus();
|
||||
await user.keyboard("CAMPAIGN");
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
"feat/campaign-pause",
|
||||
"fix/campaign-step",
|
||||
"origin/feat/campaign-statistic",
|
||||
]);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(trigger).toHaveTextContent("feat/campaign-pause");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
|
||||
branch: "feat/campaign-pause",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
@@ -355,11 +395,12 @@ describe("LaneGitBar", () => {
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||
expect(
|
||||
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
|
||||
screen.getByText("On the remote — switching creates the local branch")
|
||||
).toBeInTheDocument();
|
||||
await user.selectOptions(select, "origin/feat/new");
|
||||
await user.keyboard("{Escape}");
|
||||
await pickBranch(user, "origin/feat/new");
|
||||
// Not a local branch yet: nothing to delete, no worktree to start on it.
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
|
||||
@@ -395,7 +436,7 @@ describe("LaneGitBar", () => {
|
||||
})
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "New workspace here" }));
|
||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||
});
|
||||
@@ -413,18 +454,18 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await screen.findByRole("button", { name: "Switch branch…" });
|
||||
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
|
||||
|
||||
// This lane's own branch: its worktree is this lane.
|
||||
await user.selectOptions(select, "feat/thing");
|
||||
await pickBranch(user, "feat/thing");
|
||||
expect(newLane()).toBeDisabled();
|
||||
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
||||
await user.selectOptions(select, "development");
|
||||
await pickBranch(user, "development");
|
||||
expect(newLane()).toBeDisabled();
|
||||
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
|
||||
await user.selectOptions(select, "feat/free");
|
||||
await pickBranch(user, "feat/free");
|
||||
expect(newLane()).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -443,13 +484,15 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
||||
expect(await screen.findByRole("button", { name: "Switch branch…" })).toHaveTextContent(
|
||||
"Switch branch…"
|
||||
);
|
||||
});
|
||||
|
||||
it("sends one branch-create for a double Enter", async () => {
|
||||
@@ -476,7 +519,7 @@ describe("LaneGitBar", () => {
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await screen.findByRole("button", { name: "Switch branch…" });
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
// Lane 3's answer lands late: its worktree held `development`.
|
||||
resolveOld({
|
||||
@@ -485,7 +528,7 @@ describe("LaneGitBar", () => {
|
||||
current: "feat/thing",
|
||||
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
||||
});
|
||||
await user.selectOptions(select, "development");
|
||||
await pickBranch(user, "development");
|
||||
const newLane = screen.getByRole("button", { name: "New workspace here" });
|
||||
expect(newLane).toBeEnabled();
|
||||
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
@@ -572,7 +615,7 @@ describe("merge (issue #45)", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @file Tests for workspace sharing in the client: WorkspaceShareModal starts
|
||||
* on the current list, never offers the owner, warns what sharing means, asks
|
||||
* once before adding someone and saves the WHOLE list; WorkspaceRowMenu offers
|
||||
* Share (server mode) and the other owner-only items only to the workspace's
|
||||
* owner or an admin; given a session, it lists and saves THAT session's
|
||||
* shares instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act, fireEvent } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import i18n from "../../../i18n";
|
||||
import { settleConfirm } from "../../../lib/confirm";
|
||||
import { WorkspaceShareModal } from "../WorkspaceShareModal";
|
||||
import { WorkspaceRowMenu } from "../WorkspaceRowMenu";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: false, me: 1, admin: false }));
|
||||
vi.mock("../../AuthGate", () => ({
|
||||
usePermissions: () => ({
|
||||
local: perms.local,
|
||||
isAdmin: perms.admin,
|
||||
ownsLane: (l?: { owner_id?: number | null } | null) =>
|
||||
perms.local || perms.admin || l?.owner_id === perms.me,
|
||||
}),
|
||||
}));
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
people: vi.fn(),
|
||||
shares: vi.fn(),
|
||||
setShares: vi.fn(),
|
||||
sessionShares: vi.fn(),
|
||||
setSessionShares: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
artifacts: { people: m.people },
|
||||
lanes: {
|
||||
shares: m.shares,
|
||||
setShares: m.setShares,
|
||||
sessionShares: m.sessionShares,
|
||||
setSessionShares: m.setSessionShares,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const lane = {
|
||||
id: 7,
|
||||
title: "Shop",
|
||||
cwd: "/home/alice/shop",
|
||||
owner_id: 1,
|
||||
kind: "adopted",
|
||||
} as Lane;
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
perms.local = false;
|
||||
perms.me = 1;
|
||||
perms.admin = false;
|
||||
m.people.mockResolvedValue({
|
||||
people: [
|
||||
{ id: 1, username: "alice", name: "Alice" },
|
||||
{ id: 2, username: "bob", name: "Bob" },
|
||||
{ id: 3, username: "carol", name: "Carol" },
|
||||
],
|
||||
});
|
||||
m.shares.mockResolvedValue({
|
||||
shares: [{ id: 2, username: "bob", name: "Bob", sharedAt: "2026-11-06T00:00:00Z" }],
|
||||
canManage: true,
|
||||
});
|
||||
m.setShares.mockImplementation(async (_id: number, ids: number[]) => ({
|
||||
shares: ids.map((id) => ({ id, username: `u${id}`, name: `U${id}`, sharedAt: "" })),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("WorkspaceShareModal", () => {
|
||||
it("starts on the current list, never offers the owner and says what sharing means", async () => {
|
||||
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||
await settle();
|
||||
const list = screen.getByTestId("workspace-share-people");
|
||||
expect(list).not.toHaveTextContent("Alice");
|
||||
expect(screen.getByRole("checkbox", { name: /Bob/ })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByRole("checkbox", { name: /Carol/ })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false"
|
||||
);
|
||||
expect(screen.getByTestId("workspace-share-warning")).toHaveTextContent(/your home folder/i);
|
||||
// Nothing changed yet: nothing to save.
|
||||
expect(screen.getByTestId("workspace-share-save")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("asks once before adding someone, then saves the whole list", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<WorkspaceShareModal lane={lane} onClose={onClose} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
|
||||
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||
await settle();
|
||||
expect(m.setShares).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(m.setShares).toHaveBeenCalledWith(7, [2, 3]);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists a collaborator disabled since, so they can be taken off", async () => {
|
||||
m.shares.mockResolvedValue({
|
||||
shares: [
|
||||
{ id: 2, username: "bob", name: "Bob", sharedAt: "" },
|
||||
{ id: 9, username: "dan", name: "Dan", sharedAt: "", disabled: true },
|
||||
],
|
||||
canManage: true,
|
||||
});
|
||||
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||
await settle();
|
||||
const dan = screen.getByRole("checkbox", { name: /Dan/ });
|
||||
expect(dan).toHaveAttribute("aria-checked", "true");
|
||||
expect(dan).toHaveTextContent("(disabled)");
|
||||
fireEvent.click(dan);
|
||||
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||
await settle();
|
||||
expect(m.setShares).toHaveBeenCalledWith(7, [2]);
|
||||
});
|
||||
|
||||
it("takes someone off without asking", async () => {
|
||||
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Bob/ }));
|
||||
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||
await settle();
|
||||
expect(m.setShares).toHaveBeenCalledWith(7, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceRowMenu on a shared workspace", () => {
|
||||
const open = () =>
|
||||
render(<WorkspaceRowMenu lane={lane} x={0} y={0} onClose={() => {}} onAction={() => {}} />);
|
||||
|
||||
it("offers its owner Share, Rename and Delete", () => {
|
||||
open();
|
||||
expect(screen.getByTestId("workspace-menu-share")).toBeInTheDocument();
|
||||
expect(screen.getByText("Rename")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete workspace…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a user it is shared with none of the owner-only items", () => {
|
||||
perms.me = 2;
|
||||
open();
|
||||
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
|
||||
expect(screen.queryByText("Rename")).toBeNull();
|
||||
expect(screen.queryByText("Delete workspace…")).toBeNull();
|
||||
expect(screen.getByText("Copy path")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no Share in local mode", () => {
|
||||
perms.local = true;
|
||||
open();
|
||||
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
|
||||
expect(screen.getByText("Rename")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceShareModal for one session", () => {
|
||||
it("reads and saves that session's own list, never the workspace's", async () => {
|
||||
m.shares.mockClear();
|
||||
m.setShares.mockClear();
|
||||
m.sessionShares.mockResolvedValue({ shares: [], canManage: true });
|
||||
m.setSessionShares.mockResolvedValue({
|
||||
shares: [{ id: 3, username: "carol", name: "Carol", sharedAt: "" }],
|
||||
});
|
||||
render(
|
||||
<WorkspaceShareModal
|
||||
lane={lane}
|
||||
session={{ id: "s-1", name: "Fix cart" }}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
await settle();
|
||||
expect(m.sessionShares).toHaveBeenCalledWith(7, "s-1");
|
||||
expect(m.shares).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Share session")).toBeTruthy();
|
||||
expect(screen.getByText("Fix cart")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await settle();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(m.setSessionShares).toHaveBeenCalledWith(7, "s-1", [3]);
|
||||
expect(m.setShares).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file RemoteDevices.tsx
|
||||
* @description The hub's view of remote clients (server mode, server/hub/):
|
||||
* a user's enrolled machines with Revoke (asked once; the server checks
|
||||
* who may and writes the audit entry), and their Company-account
|
||||
* sessions grouped device › folder with what the gateway counted. Shown in
|
||||
* Settings for the signed-in user's own machines and on the Users page's user
|
||||
* detail for an admin/analyst (`userId`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Laptop, ShieldAlert } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type RemoteDevice, type RemoteSessionRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
const fmt = (n: number) =>
|
||||
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
||||
|
||||
export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevoke: boolean }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [devices, setDevices] = useState<RemoteDevice[] | null>(null);
|
||||
const [sessions, setSessions] = useState<RemoteSessionRow[]>([]);
|
||||
const [revoking, setRevoking] = useState<RemoteDevice | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.remoteHub
|
||||
.devices(userId)
|
||||
.then((r) => setDevices(r.devices))
|
||||
.catch(() => setDevices([]));
|
||||
api.remoteHub
|
||||
.sessions(userId)
|
||||
.then((r) => setSessions(r.sessions))
|
||||
.catch(() => setSessions([]));
|
||||
}, [userId]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
// device › folder › sessions, the newest first (the API's order).
|
||||
const groups = useMemo(() => {
|
||||
const byDevice = new Map<string, Map<string, RemoteSessionRow[]>>();
|
||||
for (const s of sessions) {
|
||||
// Devices carry no machine name any more ("device"): the id tells them apart.
|
||||
const d = `${s.deviceName} #${s.deviceId}`;
|
||||
if (!byDevice.has(d)) byDevice.set(d, new Map());
|
||||
const folders = byDevice.get(d)!;
|
||||
const f = s.cwd || "—";
|
||||
if (!folders.has(f)) folders.set(f, []);
|
||||
folders.get(f)!.push(s);
|
||||
}
|
||||
return [...byDevice.entries()];
|
||||
}, [sessions]);
|
||||
|
||||
if (devices === null) return null;
|
||||
return (
|
||||
<div className="space-y-4" data-testid="remote-devices">
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.noDevices")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{devices.map((d) => (
|
||||
<li key={d.id} className="flex flex-wrap items-center gap-2 px-3 py-2 text-xs">
|
||||
<Laptop className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="font-medium text-fg-primary">
|
||||
{d.name} #{d.id}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
{d.revokedAt
|
||||
? t("companyHub.revokedAt", { time: formatDateTime(d.revokedAt) })
|
||||
: d.active
|
||||
? t("companyHub.validUntil", { time: formatDateTime(d.expiresAt) })
|
||||
: t("companyHub.expired")}
|
||||
</span>
|
||||
{d.lastSeenAt && (
|
||||
<span className="text-fg-muted">
|
||||
· {t("companyHub.lastSeen", { time: formatDateTime(d.lastSeenAt) })}
|
||||
</span>
|
||||
)}
|
||||
{canRevoke && d.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost ml-auto h-7 px-2 text-xs text-status-danger"
|
||||
onClick={() => setRevoking(d)}
|
||||
>
|
||||
{t("companyHub.revoke")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-fg-secondary">{t("companyHub.sessions")}</h4>
|
||||
{groups.map(([device, folders]) => (
|
||||
<div key={device} className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-fg-primary">{device}</div>
|
||||
{[...folders.entries()].map(([folder, rows]) => (
|
||||
<div key={folder} className="ml-3 space-y-1">
|
||||
<div className="truncate font-mono text-[11px] text-fg-muted" title={folder}>
|
||||
{folder}
|
||||
</div>
|
||||
<ul className="ml-3 space-y-0.5">
|
||||
{rows.map((s) => (
|
||||
<li
|
||||
key={s.sessionId}
|
||||
className="flex flex-wrap items-center gap-2 text-[11px]"
|
||||
>
|
||||
{s.purgedAt ? (
|
||||
<span className="text-fg-muted">
|
||||
{s.title || s.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={`/sessions/${s.sessionId}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{s.title || s.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
{s.unmonitored ? (
|
||||
<span className="inline-flex items-center gap-1 text-status-warning">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{t("companyHub.unmonitored")}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-fg-muted">
|
||||
{t("companyHub.sessionLine", {
|
||||
requests: s.requests,
|
||||
input: fmt(s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens),
|
||||
output: fmt(s.outputTokens),
|
||||
account: s.account || "—",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
· {formatDateTime(s.lastRequestAt || s.registeredAt)}
|
||||
</span>
|
||||
{s.purgedAt && (
|
||||
<span className="text-fg-muted">· {t("companyHub.purged")}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revoking && (
|
||||
<RevokeDialog
|
||||
device={revoking}
|
||||
onClose={() => setRevoking(null)}
|
||||
onDone={() => {
|
||||
setRevoking(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RevokeDialog({
|
||||
device,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
device: RemoteDevice;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.remoteHub.revoke(device.id);
|
||||
toast.success(t("companyHub.revoked", { name: device.name }));
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("companyHub.revokeTitle", { name: device.name })}
|
||||
message={t("companyHub.revokeHint")}
|
||||
confirmLabel={t("companyHub.revoke")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file RemotePolicy.tsx
|
||||
* @description The hub admin's switch for remote clients (server mode,
|
||||
* `server/hub/remote-policy.js`): whether laptops running AgentHub local may
|
||||
* connect at all. Off by default — company sessions then run only on the
|
||||
* hub, and nothing of a user's own machine reaches the hub or Anthropic.
|
||||
* Flipping it asks once what it does (no password: only an admin reaches
|
||||
* Administration, and the server checks the role and audits the change).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
export function RemotePolicy() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [allowed, setAllowed] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.remoteHub
|
||||
.policy()
|
||||
.then((p) => setAllowed(p.allowDevices))
|
||||
.catch(() => setAllowed(null));
|
||||
}, []);
|
||||
|
||||
if (allowed === null) return null;
|
||||
|
||||
const flip = async (on: boolean) => {
|
||||
if (busy) return;
|
||||
const ok = await confirmDialog({
|
||||
title: on ? t("companyHub.policyOnTitle") : t("companyHub.policyOffTitle"),
|
||||
message: on ? t("companyHub.policyOnConfirm") : t("companyHub.policyOffConfirm"),
|
||||
confirmLabel: on ? t("companyHub.policyOnAction") : t("companyHub.policyOffAction"),
|
||||
destructive: on,
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const p = await api.remoteHub.setPolicy(on);
|
||||
setAllowed(p.allowDevices);
|
||||
toast.success(
|
||||
p.allowDevices ? t("companyHub.policyOnToast") : t("companyHub.policyOffToast")
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-4 flex items-start gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||
data-testid="remote-policy"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg-primary">{t("companyHub.policyTitle")}</div>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{allowed ? t("companyHub.policyOnHint") : t("companyHub.policyOffHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={allowed}
|
||||
disabled={busy}
|
||||
label={t("companyHub.policyTitle")}
|
||||
onChange={(v) => void flip(v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
Share2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, withToken } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
@@ -116,7 +116,7 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
};
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
|
||||
const src = withToken(`/api/run/drop/${encodeURIComponent(drop[1]!)}`);
|
||||
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
|
||||
else if (DROP_TEXT_RE.test(path))
|
||||
show(
|
||||
|
||||
@@ -85,6 +85,8 @@ import {
|
||||
slotOfKey,
|
||||
} from "../../lib/paneLayout";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { usePanePresence } from "../../hooks/usePanePresence";
|
||||
|
||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||
|
||||
@@ -234,6 +236,19 @@ export function ConsoleTabs({
|
||||
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const tabCtx = useContextMenu<Tab>();
|
||||
// Server mode: who else has these panes open, and whose money a pane is.
|
||||
const presence = usePanePresence(lane.id);
|
||||
const { state: auth } = useAuth();
|
||||
const meId = auth.mode === "server" ? auth.user?.id : undefined;
|
||||
const othersIn = (tab: Tab) => {
|
||||
const seen = new Map<number, { id: number; name: string }>();
|
||||
for (const r of tab.runs) {
|
||||
for (const u of presence[r.id] ?? []) if (u.id !== meId) seen.set(u.id, u);
|
||||
}
|
||||
return [...seen.values()];
|
||||
};
|
||||
const billedTo = (tab: Tab) =>
|
||||
[...new Set(tab.runs.map((r) => r.startedBy?.name).filter(Boolean))] as string[];
|
||||
|
||||
// A group whose runs the list has not caught up with yet is not drawn.
|
||||
const tabs = useMemo<Tab[]>(
|
||||
@@ -532,6 +547,7 @@ export function ConsoleTabs({
|
||||
{tabs.map((tab, i) => {
|
||||
const active = !settings?.active && tab.index === groups.active;
|
||||
const single = tab.runs.length === 1 ? tab.focus : null;
|
||||
const others = othersIn(tab);
|
||||
const state = tabState(tab);
|
||||
if (single && renaming === single.id) {
|
||||
return (
|
||||
@@ -606,7 +622,7 @@ export function ConsoleTabs({
|
||||
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
|
||||
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
|
||||
onDoubleClick={() => single && startRename(single)}
|
||||
title={
|
||||
title={[
|
||||
single
|
||||
? [
|
||||
toolLabel(single),
|
||||
@@ -617,8 +633,14 @@ export function ConsoleTabs({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
|
||||
}
|
||||
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n"),
|
||||
...billedTo(tab).map((name) => t("tabs.billedTo", { name })),
|
||||
others.length
|
||||
? t("tabs.here", { names: others.map((u) => u.name).join(", ") })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")}
|
||||
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
@@ -627,6 +649,23 @@ export function ConsoleTabs({
|
||||
))}
|
||||
</span>
|
||||
<span className="truncate">{tabLabel(tab)}</span>
|
||||
{others.length > 0 && (
|
||||
<span
|
||||
data-testid={`console-tab-people-${tab.key}`}
|
||||
className="flex shrink-0 -space-x-1"
|
||||
aria-label={t("tabs.here", { names: others.map((u) => u.name).join(", ") })}
|
||||
>
|
||||
{others.slice(0, 3).map(({ id, name }) => (
|
||||
<span
|
||||
key={id}
|
||||
aria-hidden
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full border border-surface-1 bg-accent-solid text-[9px] font-semibold uppercase text-white"
|
||||
>
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{state && <RunDot state={state} />}
|
||||
</button>
|
||||
{tab.runs.some(canSleep) && (
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -124,6 +124,11 @@ const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v ==
|
||||
const OUTPUT_FETCH_MS = 1000;
|
||||
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||
const OUTPUT_BUSY_MS = 2000;
|
||||
/** How long Stop stays up after every busy signal dropped: the screen's busy
|
||||
* line comes and goes while the CLI redraws, and without a hold the button
|
||||
* flickered between Stop and Send mid-turn. An explicit end (Stop pressed)
|
||||
* is not held. */
|
||||
const BUSY_HOLD_MS = 2500;
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||
@@ -140,6 +145,37 @@ const storedSide = () => {
|
||||
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
|
||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||
/** When the conversation's last turn ended — its newest reply's timestamp
|
||||
* when that reply finished (`end_turn`, the "Done in …" line) and no prompt
|
||||
* came after it — else null. */
|
||||
export function endedTurnAt(messages: readonly TranscriptMessage[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.type === "session_event") continue;
|
||||
const sender = m.sender ?? (m.type === "assistant" ? "assistant" : "user");
|
||||
if (sender === "assistant") return m.stop_reason === "end_turn" ? m.timestamp : null;
|
||||
if (sender === "user" && m.content.some((c) => c.type === "text")) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `value`, except that a drop to false shows only once it has lasted
|
||||
* `holdMs`; a change of `release` (Stop pressed) lets it drop at once. */
|
||||
export function useHeldTrue(value: boolean, holdMs: number, release: unknown): boolean {
|
||||
const [held, setHeld] = useState(value);
|
||||
const released = useRef(release);
|
||||
useEffect(() => {
|
||||
if (value) return setHeld(true);
|
||||
if (released.current !== release) {
|
||||
released.current = release;
|
||||
return setHeld(false);
|
||||
}
|
||||
const timer = window.setTimeout(() => setHeld(false), holdMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [value, holdMs, release]);
|
||||
return value || held;
|
||||
}
|
||||
|
||||
export function contextWindow(model: string | null): number | null {
|
||||
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||
if (!model?.startsWith("claude-")) return null;
|
||||
@@ -150,13 +186,7 @@ export function contextWindow(model: string | null): number | null {
|
||||
|
||||
/** How full the context is: a ring and a percentage, amber then red as
|
||||
* Claude Code's auto-compact comes near. */
|
||||
function ContextMeter({
|
||||
tokens,
|
||||
model,
|
||||
}: {
|
||||
tokens: number;
|
||||
model: string | null;
|
||||
}) {
|
||||
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||
const { t } = useTranslation("run");
|
||||
const size = contextWindow(model);
|
||||
if (!size || !tokens) return null;
|
||||
@@ -616,19 +646,27 @@ export const PaneChat = memo(function PaneChat({
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
// The conversation on screen: an artifact's versions are rebuilt from it,
|
||||
// and its finished reply ends the turn (below).
|
||||
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||
// The main agent is "working" from a prompt's submit to its Stop hook, and
|
||||
// waits on the user from a Notification (a permission prompt) to the next hook.
|
||||
const [working, setWorking] = useState(false);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
// Stop presses, for the conversation's live line (issue #74).
|
||||
const [stops, setStops] = useState(0);
|
||||
const readWorking = useCallback(
|
||||
() =>
|
||||
// Reads overlap (every throttled transcript change starts one); only the
|
||||
// newest may land, or a slow older answer put back a state already left.
|
||||
const readSeq = useRef(0);
|
||||
const readWorking = useCallback(() => {
|
||||
const seq = ++readSeq.current;
|
||||
return (
|
||||
api.agents
|
||||
.list({ session_id: sessionId, status: "working" })
|
||||
// With session_id the server answers every agent of the session, its
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => {
|
||||
if (seq !== readSeq.current) return;
|
||||
const main = r.agents.filter((a) => a.type === "main");
|
||||
// A fresh start, resume or `/clear` leaves main "working" at an
|
||||
// empty prompt (SessionStart's flag) until the first prompt.
|
||||
@@ -637,9 +675,9 @@ export const PaneChat = memo(function PaneChat({
|
||||
);
|
||||
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||
})
|
||||
.catch(() => {}),
|
||||
[sessionId]
|
||||
);
|
||||
.catch(() => {})
|
||||
);
|
||||
}, [sessionId]);
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
@@ -714,7 +752,20 @@ export const PaneChat = memo(function PaneChat({
|
||||
}, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId, onScreen]);
|
||||
const busy = working || screenBusy;
|
||||
// The reply the chat already shows as "Done in …" ends the turn, hook or
|
||||
// not: a turn may end with no hook at all (no Stop, no SubagentStop) until
|
||||
// the idle Notification a minute later. Only a reply that is not the one
|
||||
// already there when `working` began counts, so a new prompt whose line the
|
||||
// transcript has not caught up with still shows Stop.
|
||||
const endedAt = useMemo(() => endedTurnAt(convMessages), [convMessages]);
|
||||
const endedRef = useRef(endedAt);
|
||||
endedRef.current = endedAt;
|
||||
const [staleEnd, setStaleEnd] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (working) setStaleEnd(endedRef.current);
|
||||
}, [working]);
|
||||
const turnOver = endedAt !== null && endedAt !== staleEnd;
|
||||
const busy = useHeldTrue((working && !turnOver) || screenBusy, BUSY_HOLD_MS, stops);
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
@@ -762,8 +813,6 @@ export const PaneChat = memo(function PaneChat({
|
||||
() => showTerminal(runId, sessionId, run.tool),
|
||||
[runId, sessionId, run.tool]
|
||||
);
|
||||
// The conversation on screen: an artifact's versions are rebuilt from it.
|
||||
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||
// An artifact card opens in this chat's own panel, like Claude Desktop's —
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
@@ -812,6 +861,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
if (!text || uploading) return;
|
||||
const out = promptForPane(prompt, images, claude);
|
||||
if (!(await command(out.text, out.files))) return;
|
||||
// A new turn: the reply now on screen is the previous one's.
|
||||
setStaleEnd(endedRef.current);
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
||||
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||
* for the next session (`agenthub.session.proxy`; the pre-rename
|
||||
* `agenthub.session.router` is still read when that is unset).
|
||||
* `agenthub.session.router` is still read when that is unset). On a local
|
||||
* machine enrolled with the company hub (`/api/remote-client`) the list ends
|
||||
* with the Company account (`agenthub.company`): a Claude session there runs through
|
||||
* the hub's gateway, and only such sessions are reported to the hub.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -39,14 +42,30 @@ export function useSessionProxy(): [string, (v: string) => void] {
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
/** The saved profiles (Native included), null until they arrive. */
|
||||
/** The remote client's Company account, as the list shows it. */
|
||||
const COMPANY: AiProxy = {
|
||||
name: "agenthub.company",
|
||||
label: "Company account",
|
||||
builtin: false,
|
||||
company: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "",
|
||||
models: {},
|
||||
};
|
||||
|
||||
/** The saved profiles (Native included, the Company account last when enrolled), null until they arrive. */
|
||||
export function useAiProxies(): AiProxy[] | null {
|
||||
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.aiProxies
|
||||
.list()
|
||||
.then((r) => alive && setProxies(r.items))
|
||||
// A hub (server mode) answers 404 here, and a failure only hides the entry.
|
||||
const enrolled = Promise.resolve()
|
||||
.then(() => api.remoteClient.status())
|
||||
.then((r) => r.enrolled)
|
||||
.catch(() => false);
|
||||
Promise.all([api.aiProxies.list().then((r) => r.items), enrolled])
|
||||
.then(([items, company]) => alive && setProxies(company ? [...items, COMPANY] : items))
|
||||
.catch(() => alive && setProxies([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -82,12 +101,19 @@ export function ProxyPicker({
|
||||
// A remembered profile that has since been deleted falls back to native,
|
||||
// or the start would be refused for a proxy the user cannot see.
|
||||
useEffect(() => {
|
||||
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
|
||||
}, [proxies, value, onChange]);
|
||||
if (!proxies || value === "native") return;
|
||||
const pick = proxies.find((r) => r.name === value);
|
||||
if (!pick || (pick.company && tool !== "claude")) onChange("native");
|
||||
}, [proxies, value, onChange, tool]);
|
||||
|
||||
const options =
|
||||
capable && proxies?.length
|
||||
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||
? proxies
|
||||
.filter((r) => !r.company || tool === "claude")
|
||||
.map((r) => ({
|
||||
value: r.name,
|
||||
label: r.builtin ? native : r.company ? t("proxy.company") : r.label,
|
||||
}))
|
||||
: [{ value: "native", label: native }];
|
||||
return (
|
||||
<div data-testid="proxy-picker">
|
||||
|
||||
@@ -72,7 +72,7 @@ import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, withToken } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
|
||||
import {
|
||||
@@ -86,7 +86,7 @@ import {
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||
import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
@@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
fontWeightBold: 700,
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
// xterm's own scrollback scrolls at the same share as the reports below.
|
||||
scrollSensitivity: SCROLL_SPEED,
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
@@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({
|
||||
const ws = wsRef.current;
|
||||
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
||||
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
|
||||
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
|
||||
const col = clamp(
|
||||
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
|
||||
term.cols
|
||||
);
|
||||
const row = clamp(
|
||||
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
|
||||
term.rows
|
||||
);
|
||||
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
||||
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
||||
};
|
||||
@@ -626,6 +634,15 @@ export const TerminalView = memo(function TerminalView({
|
||||
term.input(altArrow, true);
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter: xterm sends a plain CR, so claude/codex SUBMIT the
|
||||
// prompt. Send Ctrl+J (LF) instead — both prompts take it as a new
|
||||
// line (Option+Enter is the other way, awkward on a Mac), while a
|
||||
// shell still runs the line, as Enter did.
|
||||
if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
term.input("\n", true);
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
@@ -709,7 +726,11 @@ export const TerminalView = memo(function TerminalView({
|
||||
// own size, so a pane on a background tab is never resized by it.
|
||||
const size =
|
||||
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
// The dashboard token, when one is set, rides the query like `/ws`'s:
|
||||
// the server refuses the upgrade without it.
|
||||
const sock = new WebSocket(
|
||||
withToken(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`)
|
||||
);
|
||||
ws = sock;
|
||||
wsRef.current = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file ConsoleTabs.presence.test.tsx
|
||||
* @description Workspace sharing phase 4 on the tab bar: a tab shows who ELSE
|
||||
* has its panes open (never the viewer), and its tooltip says whose money a
|
||||
* pane a collaborator opened is.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../../i18n";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { pane, type PaneGroups } from "../../../lib/paneLayout";
|
||||
import { ConsoleTabs } from "../ConsoleTabs";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../AuthGate", () => ({
|
||||
useAuth: () => ({ state: { mode: "server", user: { id: 1, username: "alice" } } }),
|
||||
usePermissions: () => ({ local: false, isAdmin: false }),
|
||||
}));
|
||||
vi.mock("../../../hooks/usePanePresence", () => ({
|
||||
usePanePresence: () => ({
|
||||
"agenthub-lane-1": [
|
||||
{ id: 1, name: "Alice" },
|
||||
{ id: 2, name: "Bob" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
|
||||
const RUN: RunHandle = {
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
slot: 0,
|
||||
status: "running",
|
||||
cwd: "/w",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
startedBy: { id: 2, name: "Bob" },
|
||||
tool: "claude",
|
||||
};
|
||||
const GROUPS: PaneGroups = { groups: [{ root: pane(0), focus: 0 }], active: 0 };
|
||||
|
||||
describe("ConsoleTabs presence", () => {
|
||||
it("shows who else is in a pane, and whose money it is", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ConsoleTabs
|
||||
lane={LANE}
|
||||
tabs={[RUN]}
|
||||
groups={GROUPS}
|
||||
onGroupsChange={vi.fn()}
|
||||
onOpen={vi.fn()}
|
||||
onClosed={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const people = screen.getAllByTestId(/^console-tab-people-/)[0]!;
|
||||
expect(people.textContent).toBe("B");
|
||||
expect(people.getAttribute("aria-label")).toBe("Also here: Bob");
|
||||
const tab = screen.getAllByRole("tab")[0]!;
|
||||
expect(tab.getAttribute("title")).toContain("Billed to Bob");
|
||||
expect(tab.getAttribute("title")).not.toContain("Alice");
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneChat, contextWindow } from "../PaneChat";
|
||||
import { PaneChat, contextWindow, endedTurnAt } from "../PaneChat";
|
||||
import { api } from "../../../lib/api";
|
||||
import {
|
||||
answerRunMenu,
|
||||
@@ -33,16 +33,21 @@ import {
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
// What the stand-in conversation reports as on screen when asked.
|
||||
const conv = vi.hoisted(() => ({ messages: [] as unknown[] }));
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
ConversationView: ({
|
||||
onOpenFile,
|
||||
onNotStarted,
|
||||
onMessages,
|
||||
}: {
|
||||
onOpenFile?: (path: string) => void;
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
onMessages?: (messages: never[]) => void;
|
||||
}) => (
|
||||
<div data-testid="conversation">
|
||||
<button data-testid="conv-messages" onClick={() => onMessages?.(conv.messages as never[])} />
|
||||
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||
{/* The session has no row yet: the CLI has not started it. */}
|
||||
@@ -215,12 +220,51 @@ describe("PaneChat — Stop", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2600);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops Stop once the conversation shows the finished reply, with no hook at all", async () => {
|
||||
// Claude Code may end a turn with no Stop and no SubagentStop: main stays
|
||||
// "working" until the idle Notification a minute later.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(api.agents.list).mockResolvedValue({
|
||||
agents: [{ type: "main", status: "working" }],
|
||||
} as never);
|
||||
try {
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
conv.messages = [
|
||||
{
|
||||
type: "user",
|
||||
timestamp: "2026-11-04T05:00:32Z",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-11-04T05:01:25Z",
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
];
|
||||
fireEvent.click(screen.getByTestId("conv-messages"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2600);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
conv.messages = [];
|
||||
vi.mocked(api.agents.list).mockResolvedValue({ agents: [] } as never);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
|
||||
// omp, Cursor or Codex without its hooks: only the terminal knows.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
@@ -233,6 +277,10 @@ describe("PaneChat — Stop", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2600);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -240,6 +288,32 @@ describe("PaneChat — Stop", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flicker to Send when the busy line drops out for a moment", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||
try {
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
// A redraw: the spinner line is gone for a poll or two.
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||
@@ -261,6 +335,10 @@ describe("PaneChat — Stop", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2800);
|
||||
});
|
||||
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2600);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -710,3 +788,24 @@ describe("PaneChat — a Codex terminal", () => {
|
||||
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("endedTurnAt", () => {
|
||||
const user = (text: string) => ({
|
||||
type: "user",
|
||||
timestamp: "u",
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
const reply = (stop_reason: string, timestamp = "a") => ({
|
||||
type: "assistant",
|
||||
timestamp,
|
||||
stop_reason,
|
||||
content: [{ type: "text", text: "x" }],
|
||||
});
|
||||
const toolResult = { type: "user", timestamp: "r", content: [{ type: "tool_result" }] };
|
||||
it("is the finished reply's time, and null mid-turn or after a new prompt", () => {
|
||||
expect(endedTurnAt([user("hi"), reply("end_turn", "t1")] as never)).toBe("t1");
|
||||
expect(endedTurnAt([user("hi"), reply("tool_use"), toolResult] as never)).toBeNull();
|
||||
expect(endedTurnAt([user("hi"), reply("end_turn"), user("again")] as never)).toBeNull();
|
||||
expect(endedTurnAt([] as never)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
},
|
||||
aiProxies: { list: vi.fn() },
|
||||
remoteClient: { status: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -40,6 +41,55 @@ beforeEach(() => {
|
||||
vi.mocked(api.aiProxies.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native, nine] } as never);
|
||||
vi.mocked(api.remoteClient.status).mockReset().mockResolvedValue({ enrolled: false });
|
||||
});
|
||||
|
||||
describe("the Company account", () => {
|
||||
it("is listed last only on a machine enrolled with the hub", async () => {
|
||||
vi.mocked(api.remoteClient.status).mockResolvedValue({ enrolled: true });
|
||||
const { result } = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current!.map((p) => p.name)).toEqual(["native", "9router", "agenthub.company"]);
|
||||
expect(result.current![2]!.company).toBe(true);
|
||||
});
|
||||
|
||||
it("is absent when not enrolled or when the status call fails (a hub answers 404)", async () => {
|
||||
const off = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(off.result.current).not.toBeNull());
|
||||
expect(off.result.current!.map((p) => p.name)).toEqual(["native", "9router"]);
|
||||
vi.mocked(api.remoteClient.status).mockRejectedValue(new Error("404"));
|
||||
const err = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(err.result.current).not.toBeNull());
|
||||
expect(err.result.current!.map((p) => p.name)).toEqual(["native", "9router"]);
|
||||
});
|
||||
|
||||
it("is offered for Claude only; a remembered pick on another tool falls back to Native", async () => {
|
||||
const company = {
|
||||
name: "agenthub.company",
|
||||
label: "Company account",
|
||||
builtin: false,
|
||||
company: true,
|
||||
} as AiProxy;
|
||||
render(
|
||||
<ProxyPicker
|
||||
tool="claude"
|
||||
proxies={[native, company]}
|
||||
value="agenthub.company"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("Company account")).toBeInTheDocument();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ProxyPicker
|
||||
tool="codex"
|
||||
proxies={[native, company]}
|
||||
value="agenthub.company"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProxyPicker", () => {
|
||||
|
||||
@@ -43,7 +43,10 @@ const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
|
||||
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
|
||||
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
vi.mock("../../../lib/api", async () => ({
|
||||
// The real one: it reads the token from localStorage, as the page does.
|
||||
withToken: (await vi.importActual<typeof import("../../../lib/api")>("../../../lib/api"))
|
||||
.withToken,
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
@@ -236,6 +239,31 @@ describe("TerminalView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the dashboard token after its size, as /ws does (DASHBOARD_TOKEN)", () => {
|
||||
// The server refuses a /ws-pty upgrade without it: the terminal sat at
|
||||
// "reconnecting" forever while the status bar, on /ws, said Live.
|
||||
localStorage.setItem("dashboard_token", "t ok");
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28&token=t%20ok"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the token as the only query when it has no size yet", () => {
|
||||
localStorage.setItem("dashboard_token", "tok");
|
||||
termState.cols = 0;
|
||||
termState.rows = 0;
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?token=tok"
|
||||
);
|
||||
});
|
||||
|
||||
it("a terminal on a hidden tab sends no size until it is shown", () => {
|
||||
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
|
||||
// that size resized the real tmux pane under every other client.
|
||||
@@ -329,7 +357,8 @@ describe("TerminalView", () => {
|
||||
|
||||
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||
const screenBox = () => {
|
||||
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
|
||||
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
|
||||
.value as { element: HTMLElement };
|
||||
const screen = document.createElement("div");
|
||||
screen.className = "xterm-screen";
|
||||
screen.getBoundingClientRect = () =>
|
||||
@@ -350,9 +379,11 @@ describe("TerminalView", () => {
|
||||
// A notch of 6 rows: one report, at the cell under the pointer.
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
||||
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
|
||||
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
|
||||
// two reports, sent as they add up.
|
||||
ws.sent.length = 0;
|
||||
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||
for (let i = 0; i < 6; i++)
|
||||
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||||
});
|
||||
|
||||
@@ -523,9 +554,7 @@ describe("TerminalView", () => {
|
||||
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||
// (xterm 6 removed the canvas one).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
||||
true
|
||||
);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||
@@ -605,6 +634,21 @@ describe("TerminalView", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("sends Shift+Enter as Ctrl+J, a new line in claude's prompt, not a submit", () => {
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
const ev = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, cancelable: true });
|
||||
expect(handler(ev)).toBe(false);
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(inputMock).toHaveBeenCalledWith("\n", true);
|
||||
inputMock.mockClear();
|
||||
// Plain Enter and Alt+Enter stay xterm's own.
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(true);
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "Enter", altKey: true }))).toBe(true);
|
||||
expect(inputMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||
// next row's left margin, and tmux never repaints a row it believes
|
||||
|
||||
@@ -60,6 +60,10 @@ export const THEME_LABELS: Record<string, string> = {
|
||||
canvas: "Canvas",
|
||||
"orca-dark": "Orca Dark",
|
||||
"orca-light": "Orca Light",
|
||||
"sunset-cloud": "Sunset Cloud",
|
||||
terminal: "Terminal",
|
||||
"night-sea": "Night Sea",
|
||||
sakura: "Sakura",
|
||||
};
|
||||
|
||||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
|
||||
* admin's server-wide switch that allows or blocks installing a shared skill
|
||||
* or plugin from the Hub into a user's own account (`GET/PUT
|
||||
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
||||
* admin's server-wide switches: installing a shared skill or plugin from the
|
||||
* Hub into a user's own account, and asking Claude to save a viewable copy of
|
||||
* a Doc / Slides / Design it publishes (`GET/PUT /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
||||
* or for anyone the server does not let manage it (`canManage`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -24,17 +24,32 @@ export function ArtifactsHubSettings() {
|
||||
.catch(() => setSettings(null));
|
||||
}, []);
|
||||
|
||||
if (!settings?.serverMode || !settings.canManage) return null;
|
||||
if (!settings?.canManage) return null;
|
||||
return (
|
||||
<SettingsSection id="artifacts-hub" title={t("title")}>
|
||||
{settings.serverMode && (
|
||||
<SettingsSwitchRow
|
||||
label={t("installsSwitch")}
|
||||
description={t("installsSwitchHint")}
|
||||
checked={settings.installsEnabled}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
const r = await api.artifacts.setInstallsEnabled(v);
|
||||
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingsSwitchRow
|
||||
label={t("installsSwitch")}
|
||||
description={t("installsSwitchHint")}
|
||||
checked={settings.installsEnabled}
|
||||
label={t("exportsSwitch")}
|
||||
description={t("exportsSwitchHint")}
|
||||
checked={settings.exportsEnabled !== false}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
const r = await api.artifacts.setInstallsEnabled(v);
|
||||
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||
const r = await api.artifacts.setExportsEnabled(v);
|
||||
setSettings({ ...settings, exportsEnabled: r.exportsEnabled });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
|
||||
@@ -131,6 +131,8 @@ export function BackupSettings() {
|
||||
onChange={(v) => setHour(Number(v))}
|
||||
label={t("backup.hour")}
|
||||
disabled={!state || owned?.hour}
|
||||
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
|
||||
searchable={false}
|
||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file CompanyHubSettings.tsx
|
||||
* @description Settings → AI tools → Company hub (docs/plans/remote-clients).
|
||||
* On a LOCAL install: enroll this machine with the company hub (hub address +
|
||||
* the user's hub password, which goes to the hub once and is not kept — the
|
||||
* same as `agenthub remote login`), what is and is not sent, the
|
||||
* Company-account sessions sent and when, Send now and Log out
|
||||
* (`/api/remote-client`). On the HUB (server mode, signed in): the user's own
|
||||
* enrolled machines with Revoke, their Company-account sessions, and who
|
||||
* (admin/analyst) opened their sessions (`/api/remote/*`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Building2, LogOut, Upload } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type RemoteClientStatus } from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function CompanyHubSettings() {
|
||||
const { local } = usePermissions();
|
||||
return local ? <LocalHub /> : <HubSide />;
|
||||
}
|
||||
|
||||
function LocalHub() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [st, setSt] = useState<RemoteClientStatus | null>(null);
|
||||
const [hub, setHub] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.remoteClient
|
||||
.status()
|
||||
.then(setSt)
|
||||
.catch(() => setSt(null));
|
||||
}, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const enroll = () =>
|
||||
run(async () => {
|
||||
setSt(await api.remoteClient.login({ hub, username, password }));
|
||||
setPassword("");
|
||||
toast.success(t("companyHub.enrolledToast"));
|
||||
load();
|
||||
});
|
||||
const logout = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("companyHub.logoutTitle"),
|
||||
message: t("companyHub.logoutHint"),
|
||||
confirmLabel: t("companyHub.logout"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await run(async () => {
|
||||
await api.remoteClient.logout();
|
||||
load();
|
||||
});
|
||||
};
|
||||
const sendNow = () =>
|
||||
run(async () => {
|
||||
const r = await api.remoteClient.upload();
|
||||
toast.success(
|
||||
t("companyHub.sentToast", { bytes: formatBytes(r.bytes), sessions: r.sessions })
|
||||
);
|
||||
if (r.errors.length) setError(r.errors.map((e) => e.message).join("\n"));
|
||||
load();
|
||||
});
|
||||
|
||||
if (!st) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="company-hub"
|
||||
title={t("companyHub.title")}
|
||||
description={t("companyHub.description")}
|
||||
>
|
||||
<ul className="mb-3 list-disc space-y-0.5 pl-5 text-[11px] text-fg-muted">
|
||||
<li>{t("companyHub.ruleSent")}</li>
|
||||
<li>{t("companyHub.ruleNotSent")}</li>
|
||||
<li>{t("companyHub.ruleKey")}</li>
|
||||
<li>{t("companyHub.ruleOffice")}</li>
|
||||
</ul>
|
||||
{st.enrolled ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Building2 className="h-4 w-4 text-accent" />
|
||||
<span className="text-fg-primary">
|
||||
{t("companyHub.enrolledAs", { hub: st.hub, user: st.user?.username || "?" })}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
· {st.device?.name} ·{" "}
|
||||
{t("companyHub.validUntil", { time: formatDateTime(st.device?.expiresAt || "") })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary h-8 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => void sendNow()}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("companyHub.sendNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost h-8 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{t("companyHub.logout")}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">
|
||||
{t("companyHub.sentTitle")}
|
||||
</h4>
|
||||
{st.uploads && st.uploads.length > 0 ? (
|
||||
<ul
|
||||
className="divide-y divide-border rounded-lg border border-border"
|
||||
data-testid="company-uploads"
|
||||
>
|
||||
{st.uploads.map((u) => (
|
||||
<li
|
||||
key={u.sessionId}
|
||||
className="flex flex-wrap items-center gap-2 px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<Link to={`/sessions/${u.sessionId}`} className="text-accent hover:underline">
|
||||
{u.title || u.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="truncate font-mono text-fg-muted">{u.cwd}</span>
|
||||
<span className="ml-auto text-fg-muted">
|
||||
{u.lastSentAt
|
||||
? t("companyHub.sentLine", {
|
||||
bytes: formatBytes(u.bytes || 0),
|
||||
time: formatDateTime(u.lastSentAt),
|
||||
})
|
||||
: t("companyHub.notSentYet")}
|
||||
{u.ended ? ` · ${t("companyHub.ended")}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.nothingSent")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid max-w-md gap-2"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && hub && username && password) {
|
||||
e.preventDefault();
|
||||
void enroll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{st.expired && (
|
||||
<p className="text-xs text-status-warning">{t("companyHub.expiredHint")}</p>
|
||||
)}
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder="http://10.1.13.7:4820"
|
||||
value={hub}
|
||||
onChange={(e) => setHub(e.target.value)}
|
||||
aria-label={t("companyHub.hubAddress")}
|
||||
/>
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder={t("companyHub.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
aria-label={t("companyHub.username")}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input text-sm"
|
||||
placeholder={t("companyHub.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("companyHub.password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-8 w-fit text-xs"
|
||||
disabled={busy || !hub || !username || !password}
|
||||
onClick={() => void enroll()}
|
||||
>
|
||||
{t("companyHub.enroll")}
|
||||
</button>
|
||||
<p className="text-[11px] text-fg-muted">{t("companyHub.cliHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function HubSide() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [audit, setAudit] = useState<
|
||||
| {
|
||||
id: number;
|
||||
at: string;
|
||||
sessionId: string;
|
||||
reader: string | null;
|
||||
readerDisplay: string | null;
|
||||
}[]
|
||||
| null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
api.remoteHub
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit(null));
|
||||
}, []);
|
||||
// A hub without the remote routes (an older server) answers 404: show nothing.
|
||||
if (audit === null) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="company-hub"
|
||||
title={t("companyHub.hubTitle")}
|
||||
description={t("companyHub.hubDescription")}
|
||||
>
|
||||
<RemoteDevices canRevoke />
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">{t("companyHub.whoRead")}</h4>
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.nobodyRead")}</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-[11px]" data-testid="company-audit">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="text-fg-muted">
|
||||
{formatDateTime(a.at)} ·{" "}
|
||||
<span className="text-fg-primary">{a.readerDisplay || a.reader || "?"}</span> ·{" "}
|
||||
<Link to={`/sessions/${a.sessionId}`} className="text-accent hover:underline">
|
||||
{a.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -20,10 +20,11 @@
|
||||
* 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. **Commit signing**
|
||||
* (under the author): one picker signs every commit with one of the keys
|
||||
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
|
||||
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
|
||||
* default, signs with it in the same step. The private key and
|
||||
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
|
||||
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
|
||||
* sign with Delete, a way out of the old account-wide setting and of a
|
||||
* signing setup the user made themselves, and **Import a key you have** takes
|
||||
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -233,9 +235,13 @@ function GitHostForm({
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit signing: the key every commit is signed with (one picker — choosing
|
||||
* a key turns signing on, "Don't sign" off) and Import a key the user already
|
||||
* has, which signs with it in the same step unless unticked.
|
||||
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
|
||||
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
|
||||
* workspaces that need it sign), the keys that can sign, each with Delete,
|
||||
* and Import a key the user already has. The old account-wide setting
|
||||
* (every repo signed) only shows when it is on, with a button that turns it
|
||||
* off; a signing setup the user made themselves in `~/.gitconfig` can be
|
||||
* taken out (their key file stays).
|
||||
*/
|
||||
function SigningCard({
|
||||
id,
|
||||
@@ -251,51 +257,90 @@ function SigningCard({
|
||||
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const auth = useAuth().state;
|
||||
const me = auth.mode === "server" ? auth.user : null;
|
||||
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
|
||||
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
|
||||
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
|
||||
const laneSigning = id.laneSigning ?? [];
|
||||
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
|
||||
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
|
||||
const choose = async (keyId: string | null) => {
|
||||
if (keyId && signing.state === "foreign") {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signReplaceConfirm", {
|
||||
path: signing.path,
|
||||
key: keyLabel(keyId),
|
||||
}),
|
||||
confirmLabel: t("gitIdentity.signReplace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => live && setLanes(r.lanes))
|
||||
.catch(() => live && setLanes([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
|
||||
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
|
||||
const laneName = (l: Lane) => l.title || l.cwd;
|
||||
// Still signing, but its workspace is gone from the list (removed): the
|
||||
// folder keeps signing until it is turned off here.
|
||||
const orphans =
|
||||
lanes === null
|
||||
? []
|
||||
: laneSigning
|
||||
.filter((x) => !lanes.some((l) => l.id === x.laneId))
|
||||
.map(
|
||||
(x) =>
|
||||
({
|
||||
id: x.laneId,
|
||||
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
|
||||
cwd: "",
|
||||
}) as Lane
|
||||
);
|
||||
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
|
||||
|
||||
const chooseLane = async (lane: Lane, keyId: string | null) =>
|
||||
run(
|
||||
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
|
||||
keyId
|
||||
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
|
||||
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
|
||||
);
|
||||
|
||||
const clearForeign = async () => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
|
||||
confirmLabel: t("gitIdentity.signForeignRemove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
|
||||
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
|
||||
() => api.gitIdentity.setSigning(null, false, true),
|
||||
t("gitIdentity.signForeignRemoved")
|
||||
);
|
||||
};
|
||||
|
||||
const deleteKey = async (k: Key) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.removeKeyById(k.id!),
|
||||
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
|
||||
);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!form) return;
|
||||
let imported: string | null = null;
|
||||
let foreign = false;
|
||||
const ok = await run(
|
||||
async () => {
|
||||
const r = await api.gitIdentity.importKey({
|
||||
() =>
|
||||
api.gitIdentity.importKey({
|
||||
privateKey: form.text,
|
||||
...(form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
sign: form.sign,
|
||||
});
|
||||
imported = r.imported?.id ?? null;
|
||||
foreign = r.signError?.code === "EFOREIGNSIGN";
|
||||
return r;
|
||||
},
|
||||
}),
|
||||
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
|
||||
);
|
||||
if (!ok) return;
|
||||
setForm(null);
|
||||
// Saved, but the user's own signing setup was left alone: ask once.
|
||||
if (imported && foreign) await choose(imported);
|
||||
if (ok) setForm(null);
|
||||
};
|
||||
|
||||
const readFile = async (file: File | undefined) => {
|
||||
@@ -306,6 +351,8 @@ function SigningCard({
|
||||
setForm({ ...form, text });
|
||||
};
|
||||
|
||||
const usable = keys.filter((k) => k.id);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-2 rounded-lg border border-border p-3"
|
||||
@@ -321,7 +368,7 @@ function SigningCard({
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setForm({ text: "", name: "", sign: true })}
|
||||
onClick={() => setForm({ text: "", name: "" })}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importKey")}
|
||||
@@ -330,52 +377,138 @@ function SigningCard({
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
data-testid="git-signing-state"
|
||||
className={`min-w-0 flex-1 text-[11px] ${
|
||||
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
|
||||
}`}
|
||||
{signing.state === "on" && (
|
||||
<div
|
||||
data-testid="git-signing-legacy"
|
||||
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
|
||||
>
|
||||
{signing.state === "on"
|
||||
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
|
||||
: signing.state === "foreign"
|
||||
? t("gitIdentity.signForeign", { path: signing.path })
|
||||
: t("gitIdentity.signOff")}
|
||||
</span>
|
||||
{keys.length > 0 && (
|
||||
<select
|
||||
aria-label={t("gitIdentity.signKey")}
|
||||
value={signing.state === "on" ? (signing.key ?? "") : ""}
|
||||
disabled={busy || !id.writable}
|
||||
onChange={(e) => void choose(e.target.value || null)}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="">
|
||||
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
|
||||
</option>
|
||||
{keys
|
||||
.filter((k) => k.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
|
||||
}
|
||||
>
|
||||
{t("gitIdentity.signLegacyOff")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signing.state === "foreign" && (
|
||||
<div
|
||||
data-testid="git-signing-foreign"
|
||||
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{t("gitIdentity.signForeign", { path: signing.path })}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void clearForeign()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.signForeignRemove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1" data-testid="git-signing-lanes">
|
||||
<div className="text-[11px] font-medium text-fg-secondary">
|
||||
{t("gitIdentity.signLanes")}
|
||||
</div>
|
||||
{usable.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
|
||||
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
|
||||
) : (
|
||||
[...mine, ...orphans].map((l) => {
|
||||
const on = signedWith(l.id);
|
||||
return (
|
||||
<div key={l.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-xs ${
|
||||
on ? "text-status-success" : "text-fg-primary"
|
||||
}`}
|
||||
title={l.cwd || undefined}
|
||||
>
|
||||
{laneName(l)}
|
||||
</span>
|
||||
<select
|
||||
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
|
||||
value={on?.key ?? ""}
|
||||
disabled={busy || !id.writable}
|
||||
onChange={(e) => void chooseLane(l, e.target.value || null)}
|
||||
className="select !w-48 !py-1 !text-xs"
|
||||
>
|
||||
<option value="">{t("gitIdentity.signNone")}</option>
|
||||
{usable.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<Step n={1}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
|
||||
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{usable.length > 0 && (
|
||||
<div className="space-y-1" data-testid="git-signing-keys">
|
||||
<div className="text-[11px] font-medium text-fg-secondary">
|
||||
{t("gitIdentity.signKeys")}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
|
||||
</Step>
|
||||
{usable.map((k) => {
|
||||
const used = laneSigning.filter((x) => x.key === k.id).length;
|
||||
return (
|
||||
<div
|
||||
key={k.id}
|
||||
className="flex items-center gap-2 rounded border border-border px-2 py-1"
|
||||
>
|
||||
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
|
||||
<div className="truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
|
||||
title={t("gitIdentity.signHostStep")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
|
||||
onClick={() => void deleteKey(k)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
@@ -407,24 +540,14 @@ function SigningCard({
|
||||
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||
className={`${INPUT} resize-y font-mono text-[10px]`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
aria-label={t("gitIdentity.importName")}
|
||||
placeholder={t("gitIdentity.importName")}
|
||||
value={form.name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||
className={`${INPUT} !w-40 font-mono`}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.sign}
|
||||
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
|
||||
/>
|
||||
{t("gitIdentity.importSign")}
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
aria-label={t("gitIdentity.importName")}
|
||||
placeholder={t("gitIdentity.importName")}
|
||||
value={form.name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||
className={`${INPUT} !w-40 font-mono`}
|
||||
/>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file MemberPolicySettings — Settings → AI tools → Tests on this server:
|
||||
* the admin's server-wide switch that allows or blocks members' Claude from
|
||||
* running test runners and container commands on this box
|
||||
* (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`,
|
||||
* enforced by `scripts/member-guard.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 MemberPolicyState } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function MemberPolicySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const [state, setState] = useState<MemberPolicyState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (local || !isAdmin) return;
|
||||
api.aiTools
|
||||
.memberPolicy()
|
||||
.then(setState)
|
||||
.catch(() => setState(null));
|
||||
}, [local, isAdmin]);
|
||||
|
||||
if (local || !isAdmin || !state?.available) return null;
|
||||
return (
|
||||
<SettingsSection id="member-policy" title={t("memberPolicy.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("memberPolicy.switch")}
|
||||
description={t("memberPolicy.hint")}
|
||||
checked={state.allowTests}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await api.aiTools.setMemberPolicy(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
|
||||
* mode, every user picks which account of the shared account pool their own
|
||||
* new sessions run on, per tool, among the accounts their pool group may use
|
||||
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session. Renders nothing in local mode
|
||||
* or when the pool holds no account the user may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsRow, SettingsSection } from "./SettingsRow";
|
||||
|
||||
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
||||
|
||||
export function PoolAccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { local } = usePermissions();
|
||||
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
|
||||
const [busy, setBusy] = useState<PoolTool | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => setTools(null));
|
||||
}, [local]);
|
||||
|
||||
if (local || !tools?.length) return null;
|
||||
|
||||
const choose = async (tool: PoolTool, account: string | null) => {
|
||||
setBusy(tool);
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(tool, account);
|
||||
setTools((list) =>
|
||||
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
|
||||
);
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="pool-account"
|
||||
title={t("poolAccount.title")}
|
||||
description={t("poolAccount.description")}
|
||||
>
|
||||
{tools.map((x) => (
|
||||
<SettingsRow
|
||||
key={x.tool}
|
||||
id={`pool-account-${x.tool}`}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
|
||||
{LABEL[x.tool]}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
x.pin ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: x.pin })}
|
||||
</span>
|
||||
) : (
|
||||
t("poolAccount.count", { count: x.accounts.length })
|
||||
)
|
||||
}
|
||||
disabled={!!x.pin}
|
||||
control={
|
||||
<select
|
||||
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
|
||||
value={x.pin ?? x.choice ?? ""}
|
||||
disabled={!!x.pin || busy === x.tool}
|
||||
onChange={(e) => void choose(x.tool, e.target.value || null)}
|
||||
className="select !py-1 !text-xs max-w-[16rem]"
|
||||
>
|
||||
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
|
||||
{x.accounts.map((a) => (
|
||||
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
|
||||
{[
|
||||
a.name,
|
||||
a.email && a.email !== a.name ? `(${a.email})` : null,
|
||||
a.expired
|
||||
? `— ${t("poolAccount.expired")}`
|
||||
: a.full
|
||||
? `— ${t("poolAccount.full")}`
|
||||
: a.quotaPercent != null
|
||||
? `— ${Math.round(a.quotaPercent)}%`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file PoolProfiles — the shared account pool's accounts as rows of Settings →
|
||||
* AI tools → Profiles (server mode). Each account this user's pool group may
|
||||
* use is one row beside the saved profiles, plus an "Automatic" row; "Switch
|
||||
* to" picks which account the user's OWN new sessions run on
|
||||
* (`PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* Automatic leaves it to the balancer; an admin's pin locks every row. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
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";
|
||||
|
||||
/** Lower-cased emails of a tool's pool accounts — a saved profile with one is the same account. */
|
||||
export function poolEmails(entry: MyPoolTool | null | undefined): Set<string> {
|
||||
return new Set(
|
||||
(entry?.accounts ?? []).map((a) => a.email?.toLowerCase()).filter((e): e is string => !!e)
|
||||
);
|
||||
}
|
||||
|
||||
export function PoolProfiles({
|
||||
entry,
|
||||
onChange,
|
||||
}: {
|
||||
entry: MyPoolTool;
|
||||
onChange: (next: MyPoolTool) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t: tc } = useTranslation("ccConfig");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const locked = !!entry.pin;
|
||||
/** The account the user's new sessions run on now; null = Automatic. */
|
||||
const current = entry.pin ?? entry.choice;
|
||||
|
||||
const choose = async (account: string | null) => {
|
||||
setBusy(account ?? "");
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(entry.tool, account);
|
||||
onChange({ ...entry, choice: r.choice });
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const inUse = (
|
||||
<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("poolAccount.inUse")}
|
||||
</span>
|
||||
);
|
||||
const poolBadge = (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-border bg-surface-3 text-fg-muted">
|
||||
{t("poolAccount.badge")}
|
||||
</span>
|
||||
);
|
||||
const switchBtn = (account: string | null, disabled = false) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null || disabled}
|
||||
onClick={() => void choose(account)}
|
||||
className={btnCls}
|
||||
>
|
||||
{busy === (account ?? "") ? tc("profiles.switching") : tc("profiles.switchButton")}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid={`pool-profiles-${entry.tool}`}>
|
||||
{locked && (
|
||||
<p className="text-[11px] text-fg-muted inline-flex items-center gap-1.5">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: entry.pin })}
|
||||
</p>
|
||||
)}
|
||||
{!locked && (
|
||||
<div className={rowCls} data-testid={`pool-row-${entry.tool}-automatic`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-fg-primary">
|
||||
{t("poolAccount.automatic")}
|
||||
</span>
|
||||
{poolBadge}
|
||||
{current === null && inUse}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{t("poolAccount.automaticHint")}
|
||||
</span>
|
||||
{current !== null && switchBtn(null)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{entry.accounts.map((a) => {
|
||||
const status = a.expired
|
||||
? t("poolAccount.expired")
|
||||
: a.full
|
||||
? t("poolAccount.full")
|
||||
: a.quotaPercent != null
|
||||
? t("poolAccount.quota", { pct: Math.round(a.quotaPercent) })
|
||||
: null;
|
||||
return (
|
||||
<div key={a.name} className={rowCls} data-testid={`pool-row-${entry.tool}-${a.name}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-fg-primary">{a.name}</span>
|
||||
{poolBadge}
|
||||
{current === a.name && inUse}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{a.email && a.email !== a.name ? a.email : ""}
|
||||
</span>
|
||||
{!locked && current !== a.name && switchBtn(a.name, a.expired)}
|
||||
</div>
|
||||
{status && (
|
||||
<div
|
||||
className={`text-[10px] mt-0.5 ${
|
||||
a.expired || a.full ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -881,3 +881,21 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every vendor's rate table, as the Pricing page shows them (Settings in
|
||||
* local mode, Administration → Server settings in server mode). */
|
||||
export function AllPricingSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PricingSettings
|
||||
vendor="claude"
|
||||
allowReset
|
||||
id="pricing-claude"
|
||||
heading={t("pricing.vendor.claude")}
|
||||
/>
|
||||
<PricingSettings vendor="openai" id="pricing-openai" heading={t("pricing.vendor.openai")} />
|
||||
<PricingSettings vendor="google" id="pricing-google" heading={t("pricing.vendor.google")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,6 +222,8 @@ export function ReportSettings() {
|
||||
value={String(form.value.hour)}
|
||||
onChange={(v) => set({ hour: Number(v) })}
|
||||
label={t("reports.hour")}
|
||||
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
|
||||
searchable={false}
|
||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
* (`/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.
|
||||
* (Antigravity: an OS-keyring login) says why. In server mode the shared
|
||||
* account pool's accounts this user may use (`GET /api/me/pool-accounts`) are
|
||||
* rows of the same list (PoolProfiles: "Switch to" picks the account the
|
||||
* user's own new sessions run on, plus an Automatic row) — there is no
|
||||
* separate Pool account section any more. A saved profile holding the same
|
||||
* account (email) as a pool row is folded into that row. Staff also see the
|
||||
* host's saved logins and how many accounts the whole pool holds; an admin can
|
||||
* move a profile into the pool in Administration → Users
|
||||
* (`?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). A
|
||||
* developer sees only the pool rows of the tools they may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
@@ -33,11 +39,19 @@ import {
|
||||
type CcProfile,
|
||||
type CcProfileUsage,
|
||||
type CcUsageWindow,
|
||||
type MyPoolTool,
|
||||
type ToolProfile,
|
||||
type ToolProfileIdentity,
|
||||
type ToolProfilesTool,
|
||||
} from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
profileKey,
|
||||
readHiddenProfiles,
|
||||
standIn,
|
||||
toggleHidden,
|
||||
writeHiddenProfiles,
|
||||
} from "../../lib/hiddenProfilesStorage";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -46,11 +60,14 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import { SkeletonRows } from "../config/configPrimitives";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
import { PoolProfiles, poolEmails } from "./PoolProfiles";
|
||||
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in. */
|
||||
const TOOLS: { id: string; label: string; login: string }[] = [
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in.
|
||||
* `poolOnly`: no saved logins of its own — a tab only while the pool has one. */
|
||||
const TOOLS: { id: string; label: string; login: string; poolOnly?: boolean }[] = [
|
||||
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
||||
{ id: "codex", label: "Codex", login: "codex login" },
|
||||
{ id: "cursor", label: "Cursor", login: "", poolOnly: true },
|
||||
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
||||
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
||||
];
|
||||
@@ -65,15 +82,21 @@ export function ToolProfilesSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const { local, seesAll } = usePermissions();
|
||||
const [tab, setTab] = useState<string>("claude");
|
||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
/** Server mode: the pool accounts THIS user may use, per tool. */
|
||||
const [mine, setMine] = useState<MyPoolTool[] | null>(null);
|
||||
const mineOf = (tool: string) => mine?.find((x) => x.tool === tool) ?? null;
|
||||
const shown = TOOLS.filter((x) => {
|
||||
if (mineOf(x.id)) return true;
|
||||
// The host's own logins are a staff-only read (a developer would get 403s).
|
||||
if (!seesAll || x.poolOnly) return false;
|
||||
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(
|
||||
() =>
|
||||
@@ -84,9 +107,9 @@ export function ToolProfilesSettings() {
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadOthers();
|
||||
}, [loadOthers]);
|
||||
// The pool is a server-mode, staff-only read.
|
||||
if (seesAll) void loadOthers();
|
||||
}, [loadOthers, seesAll]);
|
||||
// The whole pool is a server-mode, staff-only read.
|
||||
useEffect(() => {
|
||||
if (local || !seesAll) return;
|
||||
api.accountPool
|
||||
@@ -94,10 +117,23 @@ export function ToolProfilesSettings() {
|
||||
.then(setPool)
|
||||
.catch(() => setPool(null));
|
||||
}, [local, seesAll]);
|
||||
// The accounts this user may run on: server mode, every role.
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setMine(r.tools))
|
||||
.catch(() => setMine([]));
|
||||
}, [local]);
|
||||
|
||||
if (!current) return null;
|
||||
const poolCount = (tool: string) =>
|
||||
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
||||
const entry = mineOf(current.id);
|
||||
const setEntry = (next: MyPoolTool) =>
|
||||
setMine((list) => (list ?? []).map((x) => (x.tool === next.tool ? next : x)));
|
||||
const poolRows = entry ? <PoolProfiles entry={entry} onChange={setEntry} /> : null;
|
||||
const merged = poolEmails(entry);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -125,11 +161,18 @@ export function ToolProfilesSettings() {
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="mt-3 space-y-3">
|
||||
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
{seesAll && POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
<PoolLine count={poolCount(current.id)!} />
|
||||
)}
|
||||
{current.id === "claude" ? (
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
||||
{!seesAll || current.poolOnly ? (
|
||||
poolRows
|
||||
) : current.id === "claude" ? (
|
||||
<ClaudeProfiles
|
||||
pooled={!local && POOL_TOOLS.has("claude")}
|
||||
local={local}
|
||||
merged={merged}
|
||||
poolRows={poolRows}
|
||||
/>
|
||||
) : (
|
||||
<OtherProfiles
|
||||
key={current.id}
|
||||
@@ -139,6 +182,8 @@ export function ToolProfilesSettings() {
|
||||
state={others?.find((o) => o.tool === current.id) ?? null}
|
||||
loading={others === null}
|
||||
pooled={!local && POOL_TOOLS.has(current.id)}
|
||||
merged={merged}
|
||||
poolRows={poolRows}
|
||||
onChanged={loadOthers}
|
||||
/>
|
||||
)}
|
||||
@@ -288,6 +333,8 @@ function OtherProfiles({
|
||||
state,
|
||||
loading,
|
||||
pooled,
|
||||
merged,
|
||||
poolRows,
|
||||
onChanged,
|
||||
}: {
|
||||
tool: string;
|
||||
@@ -296,6 +343,10 @@ function OtherProfiles({
|
||||
state: ToolProfilesTool | null;
|
||||
loading: boolean;
|
||||
pooled: boolean;
|
||||
/** Emails of the pool rows: a saved login of one is shown as that row. */
|
||||
merged: Set<string>;
|
||||
/** The pool accounts this user may use, as rows of this list. */
|
||||
poolRows: ReactNode;
|
||||
onChanged: () => Promise<unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -313,8 +364,8 @@ function OtherProfiles({
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const items = state.items;
|
||||
const activeSaved = items.some((p) => p.active);
|
||||
const activeSaved = state.items.some((p) => p.active);
|
||||
const items = state.items.filter((p) => !p.email || !merged.has(p.email.toLowerCase()));
|
||||
const remove = async (p: ToolProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
@@ -369,7 +420,7 @@ function OtherProfiles({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
{items.length === 0 && !poolRows && (
|
||||
<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>
|
||||
@@ -438,6 +489,7 @@ function OtherProfiles({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{poolRows}
|
||||
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
||||
</div>
|
||||
);
|
||||
@@ -475,7 +527,19 @@ function ActiveBadge() {
|
||||
// save button only appears while the live account is unsaved, because one
|
||||
// account is one profile.
|
||||
|
||||
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
function ClaudeProfiles({
|
||||
pooled,
|
||||
local,
|
||||
merged,
|
||||
poolRows,
|
||||
}: {
|
||||
pooled: boolean;
|
||||
local: boolean;
|
||||
/** Emails of the pool rows: a saved profile of one is shown as that row. */
|
||||
merged: Set<string>;
|
||||
/** The pool accounts this user may use, as rows of this list. */
|
||||
poolRows: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
@@ -492,6 +556,51 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
const ctx = useContextMenu<CcProfile>();
|
||||
/** Local mode only: profiles this browser leaves out (Alt+Shift+click a row);
|
||||
* Alt+Shift+H shows them, dimmed, until pressed again. */
|
||||
const [hidden, setHidden] = useState<string[]>(() => (local ? readHiddenProfiles() : []));
|
||||
const [reveal, setReveal] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!local) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.altKey && e.shiftKey && e.code === "KeyH") {
|
||||
e.preventDefault();
|
||||
setReveal((r) => !r);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [local]);
|
||||
const isHidden = useCallback(
|
||||
(p: { accountUuid: string | null; name?: string }) => local && hidden.includes(profileKey(p)),
|
||||
[local, hidden]
|
||||
);
|
||||
const toggleRow = (e: MouseEvent, p: CcProfile) => {
|
||||
if (!local || !e.altKey || !e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = toggleHidden(hidden, profileKey(p));
|
||||
setHidden(next);
|
||||
writeHiddenProfiles(next);
|
||||
};
|
||||
const activeHidden = !!active && !reveal && isHidden(active);
|
||||
/** Shown as the live login while the real one is hidden: the first visible
|
||||
* profile, with its own usage — a "—" there would only draw the eye. */
|
||||
const decoy =
|
||||
activeHidden && items
|
||||
? standIn(
|
||||
items.map((p) => ({ ...p, accountUuid: p.accountUuid ?? null })),
|
||||
(p) => p.active || (!!active?.accountUuid && p.accountUuid === active.accountUuid),
|
||||
hidden
|
||||
)
|
||||
: null;
|
||||
const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active);
|
||||
/** Listed as its own row: not hidden by this browser, and not a pool account
|
||||
* (that one is the pool's row — one account, one row). */
|
||||
const listed = useCallback(
|
||||
(p: CcProfile) => (reveal || !isHidden(p)) && !(p.email && merged.has(p.email.toLowerCase())),
|
||||
[reveal, isHidden, merged]
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -540,10 +649,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
if (!listed(p)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
}, [items, checkUsage, listed]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
@@ -587,7 +697,9 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
owner: activeHidden
|
||||
? decoy?.email || "?"
|
||||
: active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
@@ -603,7 +715,18 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("profiles.activeLabel")}
|
||||
</div>
|
||||
{active ? (
|
||||
{active && activeHidden ? (
|
||||
decoy ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{decoy.email || decoy.displayName || decoy.accountUuid}
|
||||
</div>
|
||||
<IdentityLine identity={decoy} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||
)
|
||||
) : active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{active.email || active.displayName || active.accountUuid}
|
||||
@@ -613,7 +736,7 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||
)}
|
||||
{active && !activeSaved && (
|
||||
{active && !activeSaved && !activeHidden && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
@@ -631,17 +754,18 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.length === 0 && (
|
||||
{items !== null && items.filter(listed).length === 0 && !poolRows && (
|
||||
<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) => (
|
||||
{items?.filter(listed).map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
className={rowCls}
|
||||
onClickCapture={(e) => toggleRow(e, p)}
|
||||
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
@@ -657,11 +781,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.active && <ActiveBadge />}
|
||||
{shownActive(p) && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!p.active && (
|
||||
{!shownActive(p) && (
|
||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
||||
</button>
|
||||
@@ -696,10 +820,12 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{poolRows}
|
||||
|
||||
{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 && (
|
||||
{!shownActive(ctx.menu.item) && !ctx.menu.item.active && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||
{t("profiles.switchButton")}
|
||||
</MenuItem>
|
||||
|
||||
@@ -53,6 +53,10 @@ describe("AppearanceSettings", () => {
|
||||
"Apple Light",
|
||||
"Canvas",
|
||||
"Orca Light",
|
||||
"Sunset Cloud",
|
||||
"Terminal",
|
||||
"Night Sea",
|
||||
"Sakura",
|
||||
]) {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
|
||||
api: {
|
||||
artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn(), setExportsEnabled: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const settings = vi.mocked(api.artifacts.settings);
|
||||
@@ -32,15 +34,23 @@ describe("ArtifactsHubSettings", () => {
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
|
||||
});
|
||||
|
||||
it("renders nothing for a non-manager or in local mode", async () => {
|
||||
it("renders nothing for a non-manager, and only the copy switch in local mode", async () => {
|
||||
settings.mockResolvedValue({ ...base, canManage: false });
|
||||
const { container, unmount } = render(<ArtifactsHubSettings />);
|
||||
await waitFor(() => expect(settings).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
settings.mockResolvedValue({ ...base, serverMode: false });
|
||||
const r = render(<ArtifactsHubSettings />);
|
||||
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
|
||||
expect(r.container).toBeEmptyDOMElement();
|
||||
settings.mockResolvedValue({ ...base, serverMode: false, exportsEnabled: true });
|
||||
vi.mocked(api.artifacts.setExportsEnabled).mockResolvedValue({
|
||||
installsEnabled: true,
|
||||
exportsEnabled: false,
|
||||
});
|
||||
render(<ArtifactsHubSettings />);
|
||||
const sw = await screen.findByRole("switch", {
|
||||
name: "Keep a viewable copy of Docs and Slides",
|
||||
});
|
||||
expect(screen.queryByRole("switch", { name: "Allow installing from the Hub" })).toBeNull();
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(api.artifacts.setExportsEnabled).toHaveBeenCalledWith(false));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @file CompanyHubSettings.test.tsx
|
||||
* @description Settings → Company hub. Local: the enroll form sends the hub
|
||||
* address, username and password once and clears the password; enrolled, it
|
||||
* lists what was sent and never shows a token; Log out asks first. Hub
|
||||
* (server mode): the user's own machines with Revoke (one confirm, no
|
||||
* password) and who opened their sessions.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "../../../i18n";
|
||||
import { settleConfirm } from "../../../lib/confirm";
|
||||
import { CompanyHubSettings } from "../CompanyHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: true, isAdmin: true, seesAll: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
remoteClient: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), upload: vi.fn() },
|
||||
remoteHub: {
|
||||
devices: vi.fn(),
|
||||
sessions: vi.fn(),
|
||||
revoke: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
policy: vi.fn(),
|
||||
setPolicy: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const enrolled = {
|
||||
enrolled: true,
|
||||
hub: "http://10.1.13.7:4820",
|
||||
user: { id: 3, username: "mia", role: "developer" },
|
||||
device: { id: 7, name: "mia-pc", expiresAt: "2027-02-01T00:00:00.000Z" },
|
||||
uploads: [
|
||||
{
|
||||
sessionId: "11111111-2222-3333-4444-555555555555",
|
||||
bytes: 2048,
|
||||
lastSentAt: "2026-11-04T02:00:00.000Z",
|
||||
ended: 0,
|
||||
title: "fix the cart",
|
||||
cwd: "/home/mia/shop",
|
||||
startedAt: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const show = async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CompanyHubSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.local = true;
|
||||
});
|
||||
|
||||
describe("CompanyHubSettings — local", () => {
|
||||
it("enrolls with the hub address, username and password, then clears the password", async () => {
|
||||
vi.mocked(api.remoteClient.status)
|
||||
.mockResolvedValueOnce({ enrolled: false })
|
||||
.mockResolvedValue(enrolled);
|
||||
vi.mocked(api.remoteClient.login).mockResolvedValue(enrolled);
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.type(screen.getByLabelText(/hub address|địa chỉ hub/i), "http://10.1.13.7:4820");
|
||||
await user.type(screen.getByLabelText(/hub username|tên đăng nhập hub/i), "mia");
|
||||
const pw = screen.getByLabelText(/^(hub password|mật khẩu hub)$/i);
|
||||
await user.type(pw, "secret-1");
|
||||
await user.click(screen.getByRole("button", { name: /enroll this machine|đăng ký máy này/i }));
|
||||
expect(api.remoteClient.login).toHaveBeenCalledWith({
|
||||
hub: "http://10.1.13.7:4820",
|
||||
username: "mia",
|
||||
password: "secret-1",
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("company-uploads")).toBeInTheDocument());
|
||||
expect(pw).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists what was sent and logs out only after confirming", async () => {
|
||||
vi.mocked(api.remoteClient.status).mockResolvedValue(enrolled);
|
||||
vi.mocked(api.remoteClient.logout).mockResolvedValue({ ok: true, wasEnrolled: true });
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
expect(screen.getByText("fix the cart")).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toMatch(/ahdev_/);
|
||||
await user.click(screen.getByRole("button", { name: /log out|đăng xuất/i }));
|
||||
expect(api.remoteClient.logout).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await waitFor(() => expect(api.remoteClient.logout).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyHubSettings — hub", () => {
|
||||
it("shows the user's machines, revokes after a confirm, and who read their sessions", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.remoteHub.audit).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
id: 1,
|
||||
at: "2026-11-04T03:00:00.000Z",
|
||||
sessionId: "abcdef12-0000",
|
||||
reader: "boss",
|
||||
readerDisplay: "Boss",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.remoteHub.devices).mockResolvedValue({
|
||||
devices: [
|
||||
{
|
||||
id: 7,
|
||||
userId: 3,
|
||||
name: "mia-pc",
|
||||
createdAt: "2026-11-01T00:00:00.000Z",
|
||||
expiresAt: "2027-02-01T00:00:00.000Z",
|
||||
lastSeenAt: null,
|
||||
lastIp: null,
|
||||
revokedAt: null,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
|
||||
vi.mocked(api.remoteHub.revoke).mockResolvedValue({ ok: true } as never);
|
||||
vi.mocked(api.remoteHub.policy).mockResolvedValue({ allowDevices: false, changedAt: null });
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await waitFor(() => expect(screen.getByText("mia-pc #7")).toBeInTheDocument());
|
||||
expect(screen.getByTestId("company-audit")).toHaveTextContent("Boss");
|
||||
await user.click(screen.getByRole("button", { name: /^(revoke|thu hồi)$/i }));
|
||||
expect(screen.queryByLabelText(/your password|mật khẩu của bạn/i)).toBeNull();
|
||||
const buttons = screen.getAllByRole("button", { name: /^(revoke|thu hồi)$/i });
|
||||
await user.click(buttons[buttons.length - 1]!);
|
||||
await waitFor(() => expect(api.remoteHub.revoke).toHaveBeenCalledWith(7));
|
||||
});
|
||||
|
||||
it("leaves the laptop switch to Administration (no switch here)", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.remoteHub.audit).mockResolvedValue({ entries: [] });
|
||||
vi.mocked(api.remoteHub.devices).mockResolvedValue({ devices: [] });
|
||||
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
|
||||
await show();
|
||||
await screen.findByTestId("remote-devices");
|
||||
expect(screen.queryByRole("switch", { name: /laptop/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,10 @@
|
||||
* 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;
|
||||
* commit signing: a key picked signs commits, an imported key signs in the
|
||||
* same step, and taking over the user's own signing key asks first.
|
||||
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
|
||||
* workspace's picker signs that workspace only, a signing key is deleted from
|
||||
* the card after a confirm, and the old account-wide setting and a setup the
|
||||
* user made themselves each have a way out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -35,6 +37,16 @@ vi.mock("../../../lib/api", () => ({
|
||||
removeHttps: vi.fn(),
|
||||
importKey: vi.fn(),
|
||||
setSigning: vi.fn(),
|
||||
setLaneSigning: vi.fn(),
|
||||
},
|
||||
lanes: {
|
||||
list: vi.fn(async () => ({
|
||||
lanes: [
|
||||
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
|
||||
{ id: 2, title: "", cwd: "/w/docs" },
|
||||
],
|
||||
counts: {},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -42,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
||||
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
||||
|
||||
const OFF = { state: "off" as const, key: null, path: null };
|
||||
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
||||
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
account: "member",
|
||||
@@ -340,7 +353,7 @@ describe("GitIdentitySettings", () => {
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("signs commits with an imported key in one step", async () => {
|
||||
it("imports a key without signing anything by itself", async () => {
|
||||
const signingKey = {
|
||||
...KEY,
|
||||
id: "id_ed25519_signing",
|
||||
@@ -348,17 +361,15 @@ describe("GitIdentitySettings", () => {
|
||||
name: "signing",
|
||||
};
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [], signing: { state: "off", key: null, path: null } })
|
||||
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
|
||||
...view({
|
||||
sshKeys: [signingKey],
|
||||
signing: { state: "on", key: "id_ed25519_signing", path: null },
|
||||
}),
|
||||
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
|
||||
imported: signingKey,
|
||||
signError: null,
|
||||
});
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
|
||||
const form = screen.getByRole("form", { name: "Import an existing private key" });
|
||||
await userEvent.type(
|
||||
@@ -368,26 +379,103 @@ describe("GitIdentitySettings", () => {
|
||||
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
|
||||
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
|
||||
sign: true,
|
||||
});
|
||||
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent(/signed with signing/);
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
// Now there is a key: each workspace gets its own picker, all "Don't sign".
|
||||
expect(
|
||||
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
|
||||
).toHaveValue("");
|
||||
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks before taking over a signing key the user set up themselves", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
it("signs one workspace only, and stops it", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/id_ed25519" } })
|
||||
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||
vi.mocked(api.gitIdentity.setLaneSigning)
|
||||
.mockResolvedValueOnce(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
|
||||
)
|
||||
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
|
||||
await userEvent.selectOptions(pick, k.id);
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
|
||||
);
|
||||
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||
expect(
|
||||
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
|
||||
).toBeTruthy();
|
||||
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
|
||||
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists a removed workspace that still signs, so it can be turned off", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent("~/.ssh/id_ed25519");
|
||||
await userEvent.selectOptions(screen.getByLabelText("Signing key"), "id_ed25519");
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
const pick = await screen.findByLabelText(
|
||||
"Signing key for Workspace #9 (removed)",
|
||||
{},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(pick).toHaveValue(k.id);
|
||||
await userEvent.selectOptions(pick, "");
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith("id_ed25519", true)
|
||||
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes a signing key from the signing card, after asking", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [k],
|
||||
signing: { state: "off", key: null, path: null },
|
||||
laneSigning: [{ laneId: 1, key: k.id }],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||
view({ sshKeys: [], signing: OFF, laneSigning: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
|
||||
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
|
||||
);
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
|
||||
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
|
||||
});
|
||||
|
||||
it("turns off the old account-wide signing and removes a setup the user made", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const legacy = await screen.findByTestId("git-signing-legacy");
|
||||
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
|
||||
const foreign = await screen.findByTestId("git-signing-foreign");
|
||||
expect(foreign).toHaveTextContent("~/.ssh/mine");
|
||||
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
|
||||
);
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file Tests for MemberPolicySettings — the admin's "allow members to run
|
||||
* tests" 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/member-policy.
|
||||
* @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 { MemberPolicySettings } from "../MemberPolicySettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } },
|
||||
}));
|
||||
|
||||
const get = vi.mocked(api.aiTools.memberPolicy);
|
||||
const set = vi.mocked(api.aiTools.setMemberPolicy);
|
||||
|
||||
describe("MemberPolicySettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.isAdmin = true;
|
||||
perms.local = false;
|
||||
});
|
||||
|
||||
it("lets an admin allow tests, off by default", async () => {
|
||||
get.mockResolvedValue({ available: true, allowTests: false });
|
||||
set.mockResolvedValue({ available: true, allowTests: true });
|
||||
render(<MemberPolicySettings />);
|
||||
const sw = await screen.findByRole("switch", { name: "Allow members to run tests" });
|
||||
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
|
||||
});
|
||||
|
||||
it("hides outside S3", async () => {
|
||||
get.mockResolvedValue({ available: false, allowTests: false });
|
||||
const { container } = render(<MemberPolicySettings />);
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("asks nothing for a non-admin or in local mode", () => {
|
||||
perms.isAdmin = false;
|
||||
const { container } = render(<MemberPolicySettings />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
perms.isAdmin = true;
|
||||
perms.local = true;
|
||||
render(<MemberPolicySettings />);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
|
||||
* hidden in local mode and with no usable account; one select per tool that
|
||||
* starts on the user's choice (or Automatic) and saves a pick through
|
||||
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
|
||||
* @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 "../../../i18n";
|
||||
import { PoolAccountSettings } from "../PoolAccountSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
|
||||
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
|
||||
}));
|
||||
|
||||
const account = (name: string) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email: `${name}@x.io`,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
async function show() {
|
||||
render(<PoolAccountSettings />);
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockReset();
|
||||
m.choosePoolAccount.mockReset();
|
||||
});
|
||||
|
||||
describe("PoolAccountSettings", () => {
|
||||
it("renders nothing in local mode or with no account to choose", async () => {
|
||||
perms.local = true;
|
||||
await show();
|
||||
expect(m.poolAccounts).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts on the user's choice and saves a new pick", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.value).toBe("two");
|
||||
await userEvent.selectOptions(select, "one");
|
||||
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
await userEvent.selectOptions(select, "");
|
||||
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
});
|
||||
|
||||
it("is locked on an admin's pin", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(select.value).toBe("one");
|
||||
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,16 @@
|
||||
* 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.
|
||||
* switched; and in server mode a profile links into the account pool, while
|
||||
* the pool accounts the user may use are rows of the same list — "Switch to"
|
||||
* picks one through `PUT /api/me/pool-accounts`, an Automatic row goes back to
|
||||
* the balancer, an admin's pin locks them, a saved profile of the same email
|
||||
* is folded into the pool row, and a developer sees only the pool rows.
|
||||
* @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 { render, screen, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../../i18n";
|
||||
@@ -38,6 +42,7 @@ const m = vi.hoisted(() => {
|
||||
},
|
||||
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
||||
pool: { list: vi.fn() },
|
||||
me: { poolAccounts: vi.fn(), choosePoolAccount: vi.fn() },
|
||||
};
|
||||
});
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -45,6 +50,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
ccConfig: m.cc,
|
||||
toolProfiles: m.tp,
|
||||
accountPool: m.pool,
|
||||
me: m.me,
|
||||
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
||||
},
|
||||
}));
|
||||
@@ -68,7 +74,9 @@ async function show() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
perms.local = true;
|
||||
perms.seesAll = true;
|
||||
m.cc.profiles.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
@@ -105,6 +113,7 @@ beforeEach(() => {
|
||||
});
|
||||
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
||||
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
||||
m.me.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — Claude", () => {
|
||||
@@ -137,6 +146,47 @@ describe("ToolProfilesSettings — Claude", () => {
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
it("leaves a profile this browser hid out of the list and asks nothing about it", async () => {
|
||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||
await show();
|
||||
await settle();
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(screen.queryByText("d@e.f")).toBeNull();
|
||||
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
|
||||
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides a row on Alt+Shift+click and shows it again with Alt+Shift+H", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.keyboard("{Alt>}{Shift>}");
|
||||
await user.click(screen.getByTestId("profile-row-spare"));
|
||||
await user.keyboard("{/Shift}{/Alt}");
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.cc.hiddenProfiles")!)).toEqual(["u2"]);
|
||||
await user.keyboard("{Alt>}{Shift>}H{/Shift}{/Alt}");
|
||||
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the first visible profile as the live login while the live one is hidden", async () => {
|
||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u1"]));
|
||||
await show();
|
||||
await settle();
|
||||
expect(screen.queryByTestId("profile-row-work")).toBeNull();
|
||||
expect(screen.queryByText("a@b.c")).toBeNull();
|
||||
expect(screen.queryByText("—")).toBeNull();
|
||||
const spare = screen.getByTestId("profile-row-spare");
|
||||
expect(spare).toHaveTextContent(/active/);
|
||||
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
|
||||
expect(screen.getAllByText("d@e.f").length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not hide anything in server mode", async () => {
|
||||
perms.local = false;
|
||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||
await show();
|
||||
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — other tools", () => {
|
||||
@@ -176,3 +226,96 @@ describe("ToolProfilesSettings — account pool", () => {
|
||||
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
||||
});
|
||||
});
|
||||
|
||||
const poolAccount = (name: string, email: string | null = `${name}@x.io`) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — pool accounts as profiles", () => {
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.me.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "claude",
|
||||
pin: null,
|
||||
choice: "two",
|
||||
accounts: [poolAccount("one", "d@e.f"), poolAccount("two")],
|
||||
},
|
||||
],
|
||||
});
|
||||
m.me.choosePoolAccount.mockImplementation(async (tool: string, account: string | null) => ({
|
||||
tool,
|
||||
choice: account,
|
||||
switched: account ? "switched" : null,
|
||||
}));
|
||||
});
|
||||
|
||||
it("lists the pool accounts beside the saved profiles, one row per account", async () => {
|
||||
await show();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("Quota 20%");
|
||||
expect(screen.getByTestId("pool-row-claude-two")).toHaveTextContent("in use");
|
||||
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
|
||||
// "spare" is d@e.f — the pool's "one": shown once, as the pool row.
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("switches the user's own sessions to a pool account, and back to automatic", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
const one = screen.getByTestId("pool-row-claude-one");
|
||||
await user.click(within(one).getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.me.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
expect(m.cc.activateProfile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
|
||||
const auto = screen.getByTestId("pool-row-claude-automatic");
|
||||
await user.click(within(auto).getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.me.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
expect(screen.getByTestId("pool-row-claude-automatic")).toHaveTextContent("in use");
|
||||
});
|
||||
|
||||
it("locks every pool row on an admin's pin", async () => {
|
||||
m.me.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "claude",
|
||||
pin: "one",
|
||||
choice: null,
|
||||
accounts: [poolAccount("one"), poolAccount("two")],
|
||||
},
|
||||
],
|
||||
});
|
||||
await show();
|
||||
expect(screen.getByText(/pinned you to one/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pool-row-claude-automatic")).toBeNull();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
|
||||
expect(
|
||||
within(screen.getByTestId("pool-row-claude-two")).queryByRole("button", { name: "Switch to" })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a developer only the pool rows, never the host's logins", async () => {
|
||||
perms.seesAll = false;
|
||||
await show();
|
||||
expect(m.cc.profiles).not.toHaveBeenCalled();
|
||||
expect(m.tp.list).not.toHaveBeenCalled();
|
||||
expect(m.pool.list).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /codex/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("is not shown to a developer with no pool account to use", async () => {
|
||||
perms.seesAll = false;
|
||||
m.me.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("tablist")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* `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;
|
||||
* Cursor API key — or removes one (asked first; no password: the server checks the role);
|
||||
* 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`, from a `MemberPicker` filtered by role
|
||||
@@ -628,7 +628,7 @@ function NoPoolPanel({ id, label }: { id: string; label: string }) {
|
||||
/**
|
||||
* 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
|
||||
* them back to the least-used account. Saved in one request, password again.
|
||||
* them back to the least-used account. Saved in one request.
|
||||
*/
|
||||
function EditAccountDialog({
|
||||
pool,
|
||||
@@ -646,7 +646,6 @@ function EditAccountDialog({
|
||||
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -658,7 +657,7 @@ function EditAccountDialog({
|
||||
}, []);
|
||||
|
||||
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||
const ready = changed && current.length > 0;
|
||||
const ready = changed;
|
||||
const groupOf = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||
|
||||
@@ -667,7 +666,7 @@ function EditAccountDialog({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
|
||||
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen]));
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
@@ -723,16 +722,6 @@ function EditAccountDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
@@ -773,7 +762,6 @@ function AddAccountDialog({
|
||||
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -787,9 +775,9 @@ function AddAccountDialog({
|
||||
|
||||
const fromProfile = source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
(fromProfile ? profile.length > 0 : name.trim().length > 0 && secret.trim().length > 0);
|
||||
const ready = fromProfile
|
||||
? profile.length > 0
|
||||
: name.trim().length > 0 && secret.trim().length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
@@ -802,7 +790,7 @@ function AddAccountDialog({
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
try {
|
||||
const r = await api.accountPool.add(body, current);
|
||||
const r = await api.accountPool.add(body);
|
||||
onDone(r.account.name);
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
@@ -914,16 +902,6 @@ function AddAccountDialog({
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
@@ -944,16 +922,15 @@ function RemoveAccountDialog({
|
||||
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;
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.accountPool.remove(row.tool, row.name, current);
|
||||
await api.accountPool.remove(row.tool, row.name);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
@@ -970,7 +947,6 @@ function RemoveAccountDialog({
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
@@ -983,16 +959,6 @@ function RemoveAccountDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -8,19 +8,18 @@
|
||||
* runs an old tmux after an upgrade.
|
||||
* An admin restarts one member's server, or every idle outdated one at once
|
||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||
* time with their own password again; a restart ends that member's panes,
|
||||
* time asked once; a restart ends that member's panes,
|
||||
* and the next start runs the installed tmux. An analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RotateCcw, TerminalSquare } from "lucide-react";
|
||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
@@ -135,21 +134,20 @@ function RestartDialog({
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const one = target === "idle" ? null : target;
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (one) {
|
||||
await api.users.tmuxRestart(one.id, current);
|
||||
await api.users.tmuxRestart(one.id);
|
||||
onDone(t("tmux.done", { name: one.username }));
|
||||
} else {
|
||||
const r = await api.users.tmuxRestartIdle(current);
|
||||
const r = await api.users.tmuxRestartIdle();
|
||||
// A server that would not stop needs root: say which, the rest went.
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||
@@ -176,45 +174,14 @@ function RestartDialog({
|
||||
confirmLabel={t("tmux.restartButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
* there (its members come along and the emptied pool is deleted on save). A
|
||||
* column's menu moves an account — or a saved login profile, moved into the
|
||||
* pool on save — in without dragging; Pick members ticks members by role; a
|
||||
* pool is renamed in place. Nothing is sent until Save, which asks for the
|
||||
* admin's own password ONCE and applies every change: profiles moved in
|
||||
* pool is renamed in place. Every change SAVES ITSELF (a moment after the
|
||||
* last edit, so a rename is not sent per keystroke): profiles moved in
|
||||
* (`POST /api/accounts`), new pools created, each changed pool set exactly
|
||||
* (`PATCH`), merged pools deleted (`DELETE`). The trash on a saved pool
|
||||
* deletes it on its own.
|
||||
* (`PATCH`), merged pools deleted (`DELETE`). A new pool starts with a name
|
||||
* ("Pool 3") so it can be saved at once; a pool whose name was cleared waits.
|
||||
* While a save runs the board takes no drag; a failed save says why, keeps
|
||||
* what went through, and is tried again on the next change or by Retry. The
|
||||
* trash on a saved pool deletes it (asked first). No password: only an admin
|
||||
* edits, and the server checks the role.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -31,7 +35,6 @@ import {
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
@@ -76,7 +79,7 @@ type Draft = {
|
||||
userIds: number[];
|
||||
accounts: Record<PoolTool, string[]>;
|
||||
};
|
||||
/** A saved pool merged into another: deleted on Save. */
|
||||
/** A saved pool merged into another: deleted on the next save. */
|
||||
type Removed = { id: number; name: string; into: string };
|
||||
/** What is being dragged; `from` = the column key it left (null = shared). */
|
||||
type Drag =
|
||||
@@ -119,10 +122,21 @@ export function PoolGroups({
|
||||
renderAccount: (tool: PoolTool, name: string) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [draft, setDraft] = useState<Draft[]>(() => fromPool(pool));
|
||||
const [removed, setRemoved] = useState<Removed[]>([]);
|
||||
const [draft, setDraftNow] = useState<Draft[]>(() => fromPool(pool));
|
||||
const [removed, setRemovedNow] = useState<Removed[]>([]);
|
||||
// While a save runs the board is frozen: an edit made then would be thrown
|
||||
// away by the re-read that follows a good save (and a pool still being
|
||||
// created could be created twice), so every user edit goes through these.
|
||||
const savingNow = useRef(false);
|
||||
const saveRun = useRef(0);
|
||||
const setDraft: typeof setDraftNow = (v) => {
|
||||
if (!savingNow.current) setDraftNow(v);
|
||||
};
|
||||
const setRemoved: typeof setRemovedNow = (v) => {
|
||||
if (!savingNow.current) setRemovedNow(v);
|
||||
};
|
||||
const base = useRef(pool);
|
||||
// Set by Save: the next pool read replaces the board whatever it holds.
|
||||
// Set by a save: the next pool read replaces the board whatever it holds.
|
||||
const saved = useRef(false);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [profiles, setProfiles] = useState<Record<PoolTool, PoolProfile[]>>({
|
||||
@@ -134,6 +148,8 @@ export function PoolGroups({
|
||||
const [over, setOver] = useState<string | null>(null);
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// A failed save, and the board it failed on: retried once the board changes.
|
||||
const [saveError, setSaveError] = useState<{ message: string; at: string } | null>(null);
|
||||
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
@@ -146,13 +162,18 @@ export function PoolGroups({
|
||||
saved.current || (removed.length === 0 && sigAll(draft) === sigAll(fromPool(base.current)));
|
||||
base.current = pool;
|
||||
saved.current = false;
|
||||
// The re-read after a good save: the board is the server's again, edits reopen.
|
||||
if (savingNow.current) {
|
||||
savingNow.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
if (untouched) {
|
||||
setDraft(fromPool(pool));
|
||||
setRemoved([]);
|
||||
setDraftNow(fromPool(pool));
|
||||
setRemovedNow([]);
|
||||
} else {
|
||||
const ids = new Set((pool.groups ?? []).map((g) => g.id));
|
||||
setDraft((ds) => ds.filter((d) => d.id === null || ids.has(d.id)));
|
||||
setRemoved((rs) => rs.filter((r) => ids.has(r.id)));
|
||||
setDraftNow((ds) => ds.filter((d) => d.id === null || ids.has(d.id)));
|
||||
setRemovedNow((rs) => rs.filter((r) => ids.has(r.id)));
|
||||
}
|
||||
}, [pool]);
|
||||
|
||||
@@ -226,11 +247,22 @@ export function PoolGroups({
|
||||
seq.current += 1;
|
||||
return `new${seq.current}`;
|
||||
};
|
||||
/** A name no pool has yet ("Pool 3"), so a new pool can be saved at once. */
|
||||
const freshName = () => {
|
||||
const taken = new Set([
|
||||
...draft.map((d) => d.name.trim()),
|
||||
...(pool.groups ?? []).map((g) => g.name),
|
||||
]);
|
||||
let n = draft.length + 1;
|
||||
while (taken.has(t("pool.board.defaultName", { n }))) n += 1;
|
||||
return t("pool.board.defaultName", { n });
|
||||
};
|
||||
const newPool = (seed?: Partial<Pick<Draft, "userIds" | "accounts">>) => {
|
||||
const key = nextKey();
|
||||
const name = freshName();
|
||||
setDraft((ds) => [
|
||||
...ds,
|
||||
{ key, id: null, name: "", userIds: [], accounts: emptyAccounts(), ...seed },
|
||||
{ key, id: null, name, userIds: [], accounts: emptyAccounts(), ...seed },
|
||||
]);
|
||||
return key;
|
||||
};
|
||||
@@ -285,7 +317,7 @@ export function PoolGroups({
|
||||
moveAccount(drag.tool, drag.name, to);
|
||||
};
|
||||
const dragProps = (drag: Drag) =>
|
||||
isAdmin
|
||||
isAdmin && !saving
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
@@ -308,7 +340,7 @@ export function PoolGroups({
|
||||
? dragging.kind === "user" && dragging.id === drag.id
|
||||
: dragging.kind === "account" && dragging.tool === drag.tool && dragging.name === drag.name);
|
||||
const dropZone = (key: string | null) =>
|
||||
isAdmin
|
||||
isAdmin && !saving
|
||||
? {
|
||||
onDragOver: (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes(DND)) return;
|
||||
@@ -325,7 +357,7 @@ export function PoolGroups({
|
||||
}
|
||||
: {};
|
||||
|
||||
// ── What Save will do ─────────────────────────────────────────────────
|
||||
// ── What the next save does ───────────────────────────────────────────
|
||||
const original = new Map(fromPool(pool).map((d) => [d.key, sig(d)]));
|
||||
const changedGroups = draft.filter((d) => d.id === null || original.get(d.key) !== sig(d));
|
||||
const pendingProfiles = [
|
||||
@@ -346,10 +378,49 @@ export function PoolGroups({
|
||||
])
|
||||
) as Record<PoolTool, string[]>;
|
||||
const ungrouped = people.filter((u) => groupNameOf(u.id) === null).map((u) => u.id);
|
||||
const discard = () => {
|
||||
setDraft(fromPool(pool));
|
||||
setRemoved([]);
|
||||
|
||||
// ── Auto-save ─────────────────────────────────────────────────────────
|
||||
const boardSig = `${sigAll(draft)}#${removed.map((r) => r.id).join(",")}`;
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
savingNow.current = true;
|
||||
const run = ++saveRun.current;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const r = await applyBoard(draft, changedGroups, pendingProfiles, removed);
|
||||
if (r.error !== null) {
|
||||
savingNow.current = false;
|
||||
setSaving(false);
|
||||
// Keep what went through, so a retry never repeats it.
|
||||
setDraftNow(r.work);
|
||||
setRemovedNow(r.gone);
|
||||
const message = errorText(t, r.error);
|
||||
setSaveError({
|
||||
message,
|
||||
at: `${sigAll(r.work)}#${r.gone.map((x) => x.id).join(",")}`,
|
||||
});
|
||||
toast.error(t("pool.board.saveFailed", { error: message }));
|
||||
return;
|
||||
}
|
||||
// Stay frozen until the re-read lands (it replaces the board); a read
|
||||
// that never comes must not freeze it for good.
|
||||
saved.current = true;
|
||||
onChanged();
|
||||
window.setTimeout(() => {
|
||||
if (saveRun.current !== run || !savingNow.current) return;
|
||||
savingNow.current = false;
|
||||
setSaving(false);
|
||||
}, 5000);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !dirty || unnamed || saving) return;
|
||||
// The board a save just failed on is not sent again by itself: Retry, or
|
||||
// the next change, does it.
|
||||
if (saveError && saveError.at === boardSig) return;
|
||||
const timer = window.setTimeout(() => void save(), 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdmin, dirty, unnamed, saving, boardSig, saveError]);
|
||||
|
||||
const memberChip = (id: number, from: string | null) => {
|
||||
const u = byId.get(id);
|
||||
@@ -573,7 +644,10 @@ export function PoolGroups({
|
||||
{t("pool.groups.description")} {isAdmin && t("pool.board.howTo")}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 grid items-start gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
className={`mt-3 grid items-start gap-3 md:grid-cols-2 xl:grid-cols-3 ${saving ? "pointer-events-none opacity-70" : ""}`}
|
||||
aria-busy={saving}
|
||||
>
|
||||
{column(
|
||||
null,
|
||||
<div>
|
||||
@@ -599,7 +673,8 @@ export function PoolGroups({
|
||||
<input
|
||||
className="w-full rounded border border-transparent bg-transparent px-1 -mx-1 text-sm font-semibold text-fg-primary transition-colors hover:border-border focus:border-accent focus:outline-none"
|
||||
value={d.name}
|
||||
autoFocus={d.id === null && !d.name}
|
||||
readOnly={saving}
|
||||
autoFocus={d.id === null}
|
||||
onChange={(e) => patch(d.key, (x) => ({ ...x, name: e.target.value }))}
|
||||
placeholder={t("pool.groups.name")}
|
||||
aria-label={t("pool.groups.name")}
|
||||
@@ -634,6 +709,7 @@ export function PoolGroups({
|
||||
? setDeleting(existing)
|
||||
: setDraft((ds) => ds.filter((x) => x.key !== d.key))
|
||||
}
|
||||
disabled={saving}
|
||||
title={t("pool.groups.delete", { name })}
|
||||
aria-label={t("pool.groups.delete", { name })}
|
||||
className="btn-icon hover:text-status-danger"
|
||||
@@ -675,30 +751,28 @@ export function PoolGroups({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && dirty && (
|
||||
{isAdmin && (saving || saveError || unnamed) && (
|
||||
<div
|
||||
className="sticky bottom-3 z-10 mt-3 flex animate-slide-up flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-surface-1/95 px-3 py-2 shadow-xl backdrop-blur"
|
||||
data-testid="pool-board-save"
|
||||
className="sticky bottom-3 z-10 mt-3 flex animate-slide-up flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-1/95 px-3 py-2 shadow-xl backdrop-blur"
|
||||
data-testid="pool-board-status"
|
||||
role="status"
|
||||
>
|
||||
<span className="flex-1 text-xs text-fg-secondary">
|
||||
{t("pool.board.pending", {
|
||||
count: changedGroups.length + pendingProfiles.length + removed.length,
|
||||
})}
|
||||
{unnamed && (
|
||||
<span className="ml-2 text-status-warning">{t("pool.board.needsName")}</span>
|
||||
{saving ? (
|
||||
t("pool.board.saving")
|
||||
) : saveError ? (
|
||||
<span className="text-status-danger">
|
||||
{t("pool.board.saveFailed", { error: saveError.message })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-status-warning">{t("pool.board.needsName")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button type="button" onClick={discard} className="btn-ghost text-xs">
|
||||
{t("pool.board.discard")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={unnamed}
|
||||
onClick={() => setSaving(true)}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("pool.save")}
|
||||
</button>
|
||||
{saveError && !saving && !unnamed && (
|
||||
<button type="button" onClick={() => void save()} className="btn-primary text-xs">
|
||||
{t("pool.board.retry")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -722,26 +796,6 @@ export function PoolGroups({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{saving && (
|
||||
<SaveBoardDialog
|
||||
draft={draft}
|
||||
changed={changedGroups}
|
||||
profiles={pendingProfiles}
|
||||
removed={removed}
|
||||
profileLabel={profileLabel}
|
||||
onClose={() => setSaving(false)}
|
||||
onProgress={(d, r) => {
|
||||
setDraft(d);
|
||||
setRemoved(r);
|
||||
}}
|
||||
onDone={() => {
|
||||
toast.success(t("pool.board.saved"));
|
||||
setSaving(false);
|
||||
saved.current = true;
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<DeleteGroupDialog
|
||||
group={deleting}
|
||||
@@ -807,157 +861,64 @@ function PickMembersDialog({
|
||||
}
|
||||
|
||||
/**
|
||||
* Save: the admin's password once, then every staged change in order —
|
||||
* profiles moved into the pool, new pools created, changed pools set, merged
|
||||
* pools deleted. What already went through is written back into the board, so
|
||||
* a retry after a failure never moves a profile in twice, makes a pool again
|
||||
* or deletes one twice.
|
||||
* One save of the board, in order — profiles moved into the pool, new pools
|
||||
* created, changed pools set, merged pools deleted. Returns the board as far
|
||||
* as it went (`work`, `gone`) and the error that stopped it (null = all
|
||||
* done), so a retry never moves a profile in twice, makes a pool again or
|
||||
* deletes one twice.
|
||||
*/
|
||||
function SaveBoardDialog({
|
||||
draft,
|
||||
changed,
|
||||
profiles,
|
||||
removed,
|
||||
profileLabel,
|
||||
onClose,
|
||||
onProgress,
|
||||
onDone,
|
||||
}: {
|
||||
draft: Draft[];
|
||||
changed: Draft[];
|
||||
async function applyBoard(
|
||||
draft: Draft[],
|
||||
changed: Draft[],
|
||||
/** `tool|profile:name` entries to move into the pool first. */
|
||||
profiles: string[];
|
||||
profiles: string[],
|
||||
/** Saved pools merged into another, deleted last. */
|
||||
removed: Removed[];
|
||||
profileLabel: (tool: PoolTool, name: string) => string;
|
||||
onClose: () => void;
|
||||
onProgress: (d: Draft[], removed: Removed[]) => 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);
|
||||
let work = draft.map((d) => ({
|
||||
...d,
|
||||
userIds: [...d.userIds],
|
||||
accounts: { ...d.accounts },
|
||||
}));
|
||||
const todo = new Set(changed.map((d) => d.key));
|
||||
let gone = [...removed];
|
||||
try {
|
||||
for (const entry of profiles) {
|
||||
const [tool, ref] = entry.split("|") as [PoolTool, string];
|
||||
const profile = ref.slice(PROFILE.length);
|
||||
const r = await api.accountPool.add({ tool, name: profile, profile }, current);
|
||||
work = work.map((d) => ({
|
||||
...d,
|
||||
accounts: {
|
||||
...d.accounts,
|
||||
[tool]: d.accounts[tool].map((n) => (n === ref ? r.account.name : n)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
for (const d of work) {
|
||||
if (!todo.has(d.key)) continue;
|
||||
let id = d.id;
|
||||
if (id === null) {
|
||||
id = (await api.accountPool.createGroup(d.name.trim(), current)).group.id;
|
||||
const made = id;
|
||||
work = work.map((x) => (x.key === d.key ? { ...x, id: made } : x));
|
||||
}
|
||||
await api.accountPool.updateGroup(
|
||||
id,
|
||||
{
|
||||
...(d.id !== null ? { name: d.name.trim() } : {}),
|
||||
userIds: d.userIds,
|
||||
accounts: Object.fromEntries(TOOLS.map((tool) => [tool, d.accounts[tool]])),
|
||||
},
|
||||
current
|
||||
);
|
||||
todo.delete(d.key);
|
||||
}
|
||||
// Last: the merged pools' members already moved into their new pool.
|
||||
for (const r of removed) {
|
||||
await api.accountPool.deleteGroup(r.id, current);
|
||||
gone = gone.filter((x) => x.id !== r.id);
|
||||
}
|
||||
onDone();
|
||||
} catch (e) {
|
||||
onProgress(work, gone);
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
removed: Removed[]
|
||||
): Promise<{ work: Draft[]; gone: Removed[]; error: unknown }> {
|
||||
let work = draft.map((d) => ({
|
||||
...d,
|
||||
userIds: [...d.userIds],
|
||||
accounts: { ...d.accounts },
|
||||
}));
|
||||
const todo = new Set(changed.map((d) => d.key));
|
||||
let gone = [...removed];
|
||||
try {
|
||||
for (const entry of profiles) {
|
||||
const [tool, ref] = entry.split("|") as [PoolTool, string];
|
||||
const profile = ref.slice(PROFILE.length);
|
||||
const r = await api.accountPool.add({ tool, name: profile, profile });
|
||||
work = work.map((d) => ({
|
||||
...d,
|
||||
accounts: {
|
||||
...d.accounts,
|
||||
[tool]: d.accounts[tool].map((n) => (n === ref ? r.account.name : n)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.board.saveTitle")}
|
||||
message={t("pool.board.saveHint")}
|
||||
confirmLabel={t("pool.save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<ul className="mt-3 space-y-1 text-xs text-fg-secondary" data-testid="pool-board-summary">
|
||||
{profiles.map((entry) => {
|
||||
const [tool, ref] = entry.split("|") as [PoolTool, string];
|
||||
return (
|
||||
<li key={entry}>
|
||||
{t("pool.board.summaryProfile", {
|
||||
name: profileLabel(tool, ref.slice(PROFILE.length)),
|
||||
})}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{removed.map((r) => (
|
||||
<li key={`m${r.id}`}>{t("pool.board.summaryMerge", { from: r.name, into: r.into })}</li>
|
||||
))}
|
||||
{changed.map((d) => (
|
||||
<li key={d.key}>
|
||||
{t(d.id === null ? "pool.board.summaryNew" : "pool.board.summaryGroup", {
|
||||
name: d.name.trim(),
|
||||
count: d.userIds.length,
|
||||
accounts: TOOLS.reduce((n, tool) => n + d.accounts[tool].length, 0),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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")}
|
||||
/>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
for (const d of work) {
|
||||
if (!todo.has(d.key)) continue;
|
||||
let id = d.id;
|
||||
if (id === null) {
|
||||
id = (await api.accountPool.createGroup(d.name.trim())).group.id;
|
||||
const made = id;
|
||||
work = work.map((x) => (x.key === d.key ? { ...x, id: made } : x));
|
||||
}
|
||||
await api.accountPool.updateGroup(id, {
|
||||
...(d.id !== null ? { name: d.name.trim() } : {}),
|
||||
userIds: d.userIds,
|
||||
accounts: Object.fromEntries(TOOLS.map((tool) => [tool, d.accounts[tool]])),
|
||||
});
|
||||
todo.delete(d.key);
|
||||
}
|
||||
// Last: the merged pools' members already moved into their new pool.
|
||||
for (const r of removed) {
|
||||
await api.accountPool.deleteGroup(r.id);
|
||||
gone = gone.filter((x) => x.id !== r.id);
|
||||
}
|
||||
return { work, gone, error: null };
|
||||
} catch (e) {
|
||||
return { work, gone, error: e };
|
||||
}
|
||||
}
|
||||
|
||||
function DeleteGroupDialog({
|
||||
@@ -970,15 +931,14 @@ function DeleteGroupDialog({
|
||||
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;
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.accountPool.deleteGroup(group.id, current);
|
||||
await api.accountPool.deleteGroup(group.id);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
@@ -994,27 +954,9 @@ function DeleteGroupDialog({
|
||||
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}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SessionStatusBadge } from "../StatusBadge";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
||||
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
const POOL_LABEL: Record<PoolTool, string> = {
|
||||
claude: "Claude Code",
|
||||
@@ -35,6 +37,7 @@ const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
export function UserDetail({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("users");
|
||||
const { isAdmin } = usePermissions();
|
||||
const { label: toolLabel } = useAiTools();
|
||||
const [data, setData] = useState<UserDetails | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -289,6 +292,12 @@ export function UserDetail({ userId }: { userId: number }) {
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Their own machines (AgentHub local) on the company hub, and those
|
||||
machines' Company-account sessions — revocable by an admin. */}
|
||||
<Panel title={t("detail.devicesTitle")}>
|
||||
<RemoteDevices userId={userId} canRevoke={isAdmin} />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), 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`) —
|
||||
* the server validates the whole change before it applies any of it. A
|
||||
* what changed — the server validates the whole change before it applies any of it. A
|
||||
* refusal is shown inside the dialog, which stays open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -109,7 +108,6 @@ export function UserEditDialog({
|
||||
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
|
||||
);
|
||||
const quotaLabel = useQuotaLabel();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -141,7 +139,8 @@ export function UserEditDialog({
|
||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||
if (!self && role !== user.role) patch.role = role;
|
||||
if (!self && active === user.disabled) patch.disabled = !active;
|
||||
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
|
||||
if (role === "admin" && hostAccount !== Boolean(user.hostAccount))
|
||||
patch.hostAccount = hostAccount;
|
||||
if (password) patch.password = password;
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
@@ -172,14 +171,14 @@ export function UserEditDialog({
|
||||
// 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 && !quotaBad && !busy;
|
||||
const ready = changed && !weakOwn && !quotaBad && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.update(user.id, patch, current);
|
||||
await api.users.update(user.id, patch);
|
||||
onDone(t("done.updated", { name: user.displayName || user.username }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
@@ -196,10 +195,6 @@ export function UserEditDialog({
|
||||
/** Delete the user for good — asked first, with what goes, in red. */
|
||||
const remove = async () => {
|
||||
if (self || busy) return;
|
||||
if (!current) {
|
||||
setError(t("delete.needsPassword"));
|
||||
return;
|
||||
}
|
||||
const name = user.displayName || user.username;
|
||||
const ok = await confirmDialog({
|
||||
title: t("delete.title", { name }),
|
||||
@@ -211,7 +206,7 @@ export function UserEditDialog({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.remove(user.id, current);
|
||||
await api.users.remove(user.id);
|
||||
onDone(t("done.deleted", { name }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
@@ -593,17 +588,6 @@ export function UserEditDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border bg-surface-2/40 px-5 py-4">
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* line with its bar — red once used up, with when it opens again;
|
||||
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
|
||||
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
|
||||
* without one of their own gets — an admin edits it with their own password
|
||||
* without one of their own gets — an admin edits it
|
||||
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
|
||||
* setting is part of the Edit user form (`UserEditDialog`). A user over their
|
||||
* quota can start no session and their prompts are refused until it resets.
|
||||
@@ -120,7 +120,7 @@ export function parseLimit(text: string): number | null {
|
||||
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
|
||||
}
|
||||
|
||||
/** The default quota: shown to staff, edited by an admin with their own password. */
|
||||
/** The default quota: shown to staff, edited by an admin. */
|
||||
export function QuotaDefaultCard({
|
||||
quotaDefault,
|
||||
isAdmin,
|
||||
@@ -182,11 +182,10 @@ function QuotaDefaultDialog({
|
||||
const [on, setOn] = useState(current !== null);
|
||||
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
|
||||
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const value = parseLimit(limit);
|
||||
const ready = password.length > 0 && (!on || value !== null) && !busy;
|
||||
const ready = (!on || value !== null) && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -196,8 +195,7 @@ function QuotaDefaultDialog({
|
||||
await api.users.setQuotaDefault(
|
||||
on && value !== null
|
||||
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
|
||||
: null,
|
||||
password
|
||||
: null
|
||||
);
|
||||
onDone(t("quota.defaultSaved"));
|
||||
} catch (e) {
|
||||
@@ -246,18 +244,6 @@ function QuotaDefaultDialog({
|
||||
disabled={!on}
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
|
||||
<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={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
* tool's accounts (live panes and quota) and members per pool, then a tab per
|
||||
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
|
||||
* other installed tool; an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* admin adds an account with no password of their own asked (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||
* it, saved with the admin's own password; the users show on the account.
|
||||
* The board moves members and accounts between pools (drag, or a column's
|
||||
* menu) — an account sits in one pool, so a move takes it out of the other —
|
||||
* splits an account off onto New pool, merges a pool by dragging its only
|
||||
* included), or a setup-token — and removes one after a confirm, and an
|
||||
* analyst sees no add or remove control. An account's Edit ticks the users
|
||||
* who run on it; the users show on the account. The board moves members and
|
||||
* accounts between pools (drag, or a column's menu) — an account sits in one
|
||||
* pool, so a move takes it out of the other — splits an account off onto New
|
||||
* pool (named "Pool <n>" until renamed), merges a pool by dragging its only
|
||||
* account into another, adds a saved profile to a pool, shows each account's
|
||||
* plan, and saves every change with ONE password.
|
||||
* plan, and saves every change by itself a moment after the last edit — a
|
||||
* failed save says why and is sent again only by Retry, never twice over;
|
||||
* the board is frozen while a save runs, so no edit made then is lost.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -156,6 +158,16 @@ function drag(from: Element, to: Element) {
|
||||
fireEvent.drop(to, { dataTransfer });
|
||||
}
|
||||
|
||||
/**
|
||||
* The pool the server answers with, a fresh object on every read — as a real
|
||||
* re-read after a save is — so the board takes it as new.
|
||||
*/
|
||||
const serve = (pool: AccountPool) =>
|
||||
accountPool.list.mockImplementation(async () => structuredClone(pool));
|
||||
|
||||
/** The board saves itself ~600 ms after the last edit. */
|
||||
const SAVED = { timeout: 3000 };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
aiTools.list.mockResolvedValue({
|
||||
@@ -242,21 +254,23 @@ describe("AccountPoolCard", () => {
|
||||
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");
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
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.add).toHaveBeenCalledWith({
|
||||
tool: "cursor",
|
||||
name: "cu-1",
|
||||
token: "key_abc",
|
||||
})
|
||||
);
|
||||
expect(accountPool.add.mock.calls[0]).toHaveLength(1);
|
||||
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||
});
|
||||
|
||||
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
it("builds pools on the board: members, accounts and profiles moved in, saved by itself", async () => {
|
||||
serve({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
@@ -276,45 +290,45 @@ describe("AccountPoolCard", () => {
|
||||
expect(teamA).toHaveTextContent("leo");
|
||||
expect(teamA).toHaveTextContent("team-1");
|
||||
await screen.findByText("mia");
|
||||
expect(screen.queryByTestId("pool-board-save")).toBeNull(); // no Save any more
|
||||
|
||||
// The edits land at once (fireEvent), well inside the save's 600 ms wait.
|
||||
await user.click(screen.getByRole("button", { name: "New pool" }));
|
||||
const names = screen.getAllByLabelText("Pool name");
|
||||
await user.type(names[names.length - 1]!, "Team B");
|
||||
const names = screen.getAllByLabelText("Pool name") as HTMLInputElement[];
|
||||
const name = names[names.length - 1]!;
|
||||
expect(name.value).toBe("Pool 2"); // a new pool starts with a name
|
||||
fireEvent.change(name, { target: { value: "Team B" } });
|
||||
const teamB = screen.getAllByTestId("pool-group")[1]!;
|
||||
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
|
||||
expect(teamB).toHaveTextContent("mia");
|
||||
const add = within(teamB).getByLabelText("Move an account into Team B");
|
||||
await user.selectOptions(add, "codex|cx-1");
|
||||
await user.selectOptions(add, "claude|profile:work");
|
||||
fireEvent.change(add, { target: { value: "codex|cx-1" } });
|
||||
fireEvent.change(add, { target: { value: "claude|profile:work" } });
|
||||
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
|
||||
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
|
||||
expect(accountPool.updateGroup).not.toHaveBeenCalled();
|
||||
expect(accountPool.updateGroup).not.toHaveBeenCalled(); // waits for the last edit
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(2, {
|
||||
userIds: [7],
|
||||
accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] },
|
||||
}),
|
||||
SAVED
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
2,
|
||||
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||
expect(accountPool.add).toHaveBeenCalledWith({ tool: "claude", name: "work", profile: "work" });
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B");
|
||||
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
|
||||
// Team A did not change: nothing is sent for it.
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
// The profile moved in first, then the pool was made, then filled.
|
||||
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]!;
|
||||
expect(order(accountPool.add)).toBeLessThan(order(accountPool.createGroup));
|
||||
expect(order(accountPool.createGroup)).toBeLessThan(order(accountPool.updateGroup));
|
||||
});
|
||||
|
||||
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
serve({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
@@ -326,7 +340,6 @@ describe("AccountPoolCard", () => {
|
||||
],
|
||||
});
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const teamA = await screen.findByTestId("pool-group");
|
||||
const shared = screen.getByTestId("pool-shared");
|
||||
@@ -341,27 +354,88 @@ describe("AccountPoolCard", () => {
|
||||
expect(held(teamA)).toEqual(["team-2"]);
|
||||
expect(held(shared)).toContain("team-1");
|
||||
expect(teamA).toHaveTextContent("team-2");
|
||||
await user.click(
|
||||
fireEvent.click(
|
||||
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
|
||||
);
|
||||
expect(shared).toHaveTextContent("leo");
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(1, {
|
||||
name: "Team A",
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
}),
|
||||
SAVED
|
||||
);
|
||||
// The three edits went out as ONE save.
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
expect(accountPool.createGroup).not.toHaveBeenCalled();
|
||||
expect(accountPool.deleteGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says why a save failed, keeps what went through, and sends again only on Retry", async () => {
|
||||
serve(POOL);
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 4, name: "Pool 1" } });
|
||||
accountPool.updateGroup
|
||||
.mockRejectedValueOnce(new Error("disk full"))
|
||||
.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
drag(
|
||||
within(screen.getByTestId("pool-shared"))
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes("team-2"))!,
|
||||
screen.getByTestId("pool-new")
|
||||
);
|
||||
const status = await screen.findByText(/Not saved: disk full/, undefined, SAVED);
|
||||
expect(status.closest("[data-testid=pool-board-status]")).toBeInTheDocument();
|
||||
// The failed board is not re-sent by itself.
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(accountPool.updateGroup).toHaveBeenCalledTimes(2), SAVED);
|
||||
// The pool was made once: the retry fills the one already created.
|
||||
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Pool 1");
|
||||
expect(accountPool.updateGroup).toHaveBeenLastCalledWith(4, {
|
||||
name: "Pool 1",
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("freezes the board while a save runs, so no edit made then is lost", async () => {
|
||||
serve(POOL);
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
accountPool.createGroup.mockImplementation(
|
||||
() => new Promise((r) => (finish = () => r({ group: { id: 4, name: "Pool 1" } })))
|
||||
);
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
drag(
|
||||
within(screen.getByTestId("pool-shared"))
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes("team-2"))!,
|
||||
screen.getByTestId("pool-new")
|
||||
);
|
||||
await waitFor(() => expect(accountPool.createGroup).toHaveBeenCalledTimes(1), SAVED);
|
||||
const name = screen.getByDisplayValue("Pool 1");
|
||||
expect(screen.getByTestId("pool-shared").closest("[aria-busy]")).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"true"
|
||||
);
|
||||
// A rename typed mid-save is refused, not queued behind the save.
|
||||
fireEvent.change(name, { target: { value: "Renamed" } });
|
||||
expect(name).toHaveValue("Pool 1");
|
||||
finish(null);
|
||||
await waitFor(() => expect(accountPool.updateGroup).toHaveBeenCalledTimes(1), SAVED);
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(4, {
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
});
|
||||
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows each account's plan on its card", async () => {
|
||||
@@ -376,7 +450,7 @@ describe("AccountPoolCard", () => {
|
||||
});
|
||||
|
||||
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
serve({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
@@ -396,7 +470,6 @@ describe("AccountPoolCard", () => {
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
accountPool.deleteGroup.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
|
||||
await screen.findByText("mia");
|
||||
@@ -409,40 +482,32 @@ describe("AccountPoolCard", () => {
|
||||
const pools = screen.getAllByTestId("pool-group");
|
||||
expect(pools).toHaveLength(3);
|
||||
expect(held(pools[2]!)).toEqual(["team-2"]);
|
||||
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
|
||||
const name = within(pools[2]!).getByLabelText("Pool name") as HTMLInputElement;
|
||||
expect(name.value).toBe("Pool 3");
|
||||
fireEvent.change(name, { target: { value: "Solo" } });
|
||||
// Merge: Team B's only account into Team A brings mia along; Team B goes.
|
||||
drag(item(teamB!, "cx-1"), teamA!);
|
||||
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
|
||||
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
|
||||
expect(teamA).toHaveTextContent("mia");
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
name: "Team A",
|
||||
userIds: [8, 7],
|
||||
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
3,
|
||||
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
|
||||
"boss-pass"
|
||||
);
|
||||
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2), SAVED);
|
||||
expect(accountPool.deleteGroup).toHaveBeenCalledTimes(1);
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(1, {
|
||||
name: "Team A",
|
||||
userIds: [8, 7],
|
||||
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
||||
});
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo");
|
||||
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(3, {
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
});
|
||||
// The members moved before the emptied pool was deleted.
|
||||
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]!;
|
||||
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
|
||||
const last = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
Math.max(...fn.mock.invocationCallOrder);
|
||||
expect(last(accountPool.updateGroup)).toBeLessThan(last(accountPool.deleteGroup));
|
||||
});
|
||||
|
||||
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||
@@ -470,18 +535,18 @@ describe("AccountPoolCard", () => {
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm).toBeEnabled();
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
expect(accountPool.add).toHaveBeenCalledWith({
|
||||
tool: "claude",
|
||||
name: "work",
|
||||
profile: "work",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a setup-token only with the admin's own password", async () => {
|
||||
it("adds a setup-token once a name and token are typed, with no password", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
@@ -489,37 +554,35 @@ describe("AccountPoolCard", () => {
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
expect(confirm).toBeDisabled(); // no token yet
|
||||
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
||||
expect(confirm).toBeEnabled();
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "team-2", token: "sk-ant-oat01-xyz" },
|
||||
"boss-pass"
|
||||
)
|
||||
expect(accountPool.add).toHaveBeenCalledWith({
|
||||
tool: "claude",
|
||||
name: "team-2",
|
||||
token: "sk-ant-oat01-xyz",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("removes an account with the admin's own password", async () => {
|
||||
it("removes an account after a confirm, with no password", async () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
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" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||
);
|
||||
expect(accountPool.remove).not.toHaveBeenCalled();
|
||||
await user.click(await screen.findByRole("button", { name: "Remove" }));
|
||||
await waitFor(() => expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1"));
|
||||
});
|
||||
|
||||
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
||||
it("sets who runs on an account from its Edit, with no password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
pins: {
|
||||
@@ -550,12 +613,9 @@ describe("AccountPoolCard", () => {
|
||||
const save = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save"
|
||||
)!;
|
||||
expect(save).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save).toBeEnabled();
|
||||
await user.click(save);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||
);
|
||||
await waitFor(() => expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7]));
|
||||
});
|
||||
|
||||
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
|
||||
@@ -574,16 +634,12 @@ describe("AccountPoolCard", () => {
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||
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"
|
||||
)
|
||||
expect(accountPool.add).toHaveBeenCalledWith({ tool: "codex", name: "cx", profile: "cx" })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* user per account, names the installed tmux and warns until tmux-member is
|
||||
* installed; a row's cell shows the version with a badge when it is older than
|
||||
* the installed tmux, and "—" for a member with no server; an admin restarts
|
||||
* one member — or every idle outdated one, the working member left out — only
|
||||
* with their own password typed; an analyst gets no "Restart idle".
|
||||
* one member — or every idle outdated one, the working member left out — after
|
||||
* one confirm (no password); an analyst gets no "Restart idle".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -112,16 +112,15 @@ describe("MemberTmux", () => {
|
||||
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restarts one member only with the admin's password", async () => {
|
||||
it("restarts one member after a confirm, with no password", async () => {
|
||||
users.tmuxRestart.mockResolvedValue({});
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
||||
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
||||
const go = screen.getByRole("button", { name: "Restart" });
|
||||
expect(go).toBeDisabled();
|
||||
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||
await userEvent.click(go);
|
||||
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw"));
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
expect(users.tmuxRestart).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
|
||||
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2));
|
||||
});
|
||||
|
||||
it("restarts every idle outdated member at once, the working one left out", async () => {
|
||||
@@ -129,8 +128,7 @@ describe("MemberTmux", () => {
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
|
||||
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
|
||||
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw"));
|
||||
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @file UserEditDialog.test.tsx
|
||||
* @description The admin's account editor: Save stays off until something
|
||||
* changed AND the admin typed their own password; one PATCH carries only the
|
||||
* changed fields (profile, role, disable, workspace add/remove, sign-out); an
|
||||
* admin editing their own row cannot touch role or state; Generate fills a
|
||||
* 16-character password.
|
||||
* changed (no password of the admin's own is asked); one PATCH carries only
|
||||
* the changed fields (profile, role, disable, workspace add/remove, sign-out);
|
||||
* an admin editing their own row cannot touch role or state; Delete asks a red
|
||||
* confirm first; Generate fills a 16-character password.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -72,12 +72,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("UserEditDialog", () => {
|
||||
it("needs a change and the admin's own password before Save", async () => {
|
||||
it("needs a change before Save, and asks for no password", async () => {
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
expect(save().hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet
|
||||
await ue.type(screen.getByLabelText("Display name"), "Mia");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
@@ -94,22 +93,18 @@ describe("UserEditDialog", () => {
|
||||
await ue.selectOptions(screen.getByLabelText("Add workspace"), "7");
|
||||
await ue.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" }));
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(save());
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
2,
|
||||
{
|
||||
email: "mia@x.vn",
|
||||
role: "analyst",
|
||||
disabled: true,
|
||||
addWorkspaces: [7],
|
||||
removeWorkspaces: [5],
|
||||
signOut: true,
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
expect(users.update).toHaveBeenCalledWith(2, {
|
||||
email: "mia@x.vn",
|
||||
role: "analyst",
|
||||
disabled: true,
|
||||
addWorkspaces: [7],
|
||||
removeWorkspaces: [5],
|
||||
signOut: true,
|
||||
})
|
||||
);
|
||||
expect(users.update.mock.calls[0]).toHaveLength(2);
|
||||
expect(onDone).toHaveBeenCalledWith("mia updated");
|
||||
});
|
||||
|
||||
@@ -125,21 +120,15 @@ describe("UserEditDialog", () => {
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("New password"), "abc");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a user after the admin's password and a red confirm that says what goes", async () => {
|
||||
it("deletes a user after a red confirm that says what goes, with no password", async () => {
|
||||
users.remove.mockReset().mockResolvedValue({ user: mia });
|
||||
confirmDialog.mockReset().mockResolvedValue(true);
|
||||
const ue = userEvent.setup();
|
||||
const { onDone } = open();
|
||||
await screen.findByText("api");
|
||||
// No password yet: asked for it, nothing sent.
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password");
|
||||
expect(users.remove).not.toHaveBeenCalled();
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -147,18 +136,18 @@ describe("UserEditDialog", () => {
|
||||
message: expect.stringContaining("/srv/agenthub/users/mia"),
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => {
|
||||
it("sends nothing when the delete confirm is cancelled", async () => {
|
||||
users.remove.mockReset();
|
||||
confirmDialog.mockReset().mockResolvedValue(false);
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
expect(users.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file UserQuota.test.tsx
|
||||
* @description Spend quotas on the Users page: a row's "spent of limit" line
|
||||
* (red once used up, "(default)" when it is the default), the default quota
|
||||
* card (an admin edits it with their own password, an analyst only reads),
|
||||
* card (an admin edits it with no password asked, an analyst only reads),
|
||||
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -67,7 +67,7 @@ describe("QuotaSummary", () => {
|
||||
});
|
||||
|
||||
describe("QuotaDefaultCard", () => {
|
||||
it("an admin sets the default with their own password", async () => {
|
||||
it("an admin sets the default, with no password asked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
|
||||
@@ -77,14 +77,13 @@ describe("QuotaDefaultCard", () => {
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "25");
|
||||
await user.selectOptions(screen.getByLabelText("Period"), "5h");
|
||||
const save = screen.getByRole("button", { name: "Save" });
|
||||
expect(save).toBeDisabled(); // no password yet
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
await user.click(save);
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
expect(users.setQuotaDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit_usd: 25, period: "5h" }),
|
||||
"pw"
|
||||
expect.objectContaining({ limit_usd: 25, period: "5h" })
|
||||
);
|
||||
expect(users.setQuotaDefault.mock.calls[0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an analyst only reads it", () => {
|
||||
@@ -131,14 +130,11 @@ describe("UserEditDialog quota", () => {
|
||||
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
|
||||
await user.selectOptions(select, "custom");
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "40");
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
|
||||
"pw"
|
||||
);
|
||||
expect(users.update).toHaveBeenCalledWith(7, {
|
||||
quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("No limit is a change; an empty custom limit keeps Save off", async () => {
|
||||
@@ -146,13 +142,12 @@ describe("UserEditDialog quota", () => {
|
||||
render(
|
||||
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
|
||||
);
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
|
||||
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,19 @@ describe("useTheme", () => {
|
||||
expect(document.documentElement.dataset.theme).toBe("orca-light");
|
||||
});
|
||||
|
||||
it("applies the ai-chat-themes palettes by id, each in its own mode only", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
for (const name of ["sunset-cloud", "terminal", "night-sea"]) {
|
||||
act(() => result.current.setThemeFor("dark", name));
|
||||
expect(document.documentElement.dataset.theme).toBe(name);
|
||||
}
|
||||
act(() => result.current.setThemeFor("dark", "sakura"));
|
||||
expect(result.current.themeFor.dark).toBe("night-sea");
|
||||
act(() => result.current.setThemeFor("light", "sakura"));
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(document.documentElement.dataset.theme).toBe("sakura");
|
||||
});
|
||||
|
||||
it("the default theme carries no data-theme attribute", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
|
||||
@@ -53,12 +53,16 @@ function load(): Promise<void> {
|
||||
}
|
||||
|
||||
function onMessage(msg: WSMessage) {
|
||||
// A workspace shared with this user, or taken back: re-read the list.
|
||||
if (msg.type === "lane_access") return void load();
|
||||
if (msg.type !== "lane_update") return;
|
||||
const payload = msg.data as { lane?: Lane; removed?: number };
|
||||
const lane = payload.lane;
|
||||
if (payload.removed !== undefined || !lane || !lanes) return void load();
|
||||
const i = lanes.findIndex((l) => l.id === lane.id);
|
||||
if (i === -1) return void load();
|
||||
// A broadcast carries no viewer's `access`: a lane seen through a shared
|
||||
// session re-reads it (its access may have changed too).
|
||||
if (i === -1 || lanes[i]?.access) return void load();
|
||||
const next = [...lanes];
|
||||
next[i] = lane;
|
||||
publish(next);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file usePanePresence.ts
|
||||
* @description Who has each of a workspace's panes open (server mode, workspace
|
||||
* sharing phase 4): `GET /api/lanes/:id/presence` once, then every
|
||||
* `pane_presence` frame for that workspace, and a re-read on a WebSocket
|
||||
* reconnect (frames missed while away are never replayed). Local mode has
|
||||
* one user and reads nothing. Returns `{runId: [{id, name}]}`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
|
||||
export type PanePeople = Record<string, { id: number; name: string }[]>;
|
||||
|
||||
export function usePanePresence(laneId: number | null | undefined): PanePeople {
|
||||
const { local } = usePermissions();
|
||||
const [panes, setPanes] = useState<PanePeople>({});
|
||||
useEffect(() => {
|
||||
setPanes({});
|
||||
if (local || laneId == null) return;
|
||||
let alive = true;
|
||||
const load = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.presence(laneId))
|
||||
.then((r) => alive && setPanes(r.panes))
|
||||
.catch(() => {
|
||||
/* presence is a nicety: no list, no chips */
|
||||
});
|
||||
void load();
|
||||
const offMsg = eventBus.subscribe((msg) => {
|
||||
if (msg.type !== "pane_presence") return;
|
||||
const d = msg.data as { laneId?: number; runId?: string; users?: PanePeople[string] };
|
||||
if (d.laneId !== laneId || !d.runId) return;
|
||||
setPanes((prev) => {
|
||||
const next = { ...prev };
|
||||
if (d.users?.length) next[d.runId!] = d.users;
|
||||
else delete next[d.runId!];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
const offConn = eventBus.onConnection((up) => {
|
||||
if (up) void load();
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
offMsg();
|
||||
offConn();
|
||||
};
|
||||
}, [laneId, local]);
|
||||
return panes;
|
||||
}
|
||||
@@ -22,8 +22,8 @@ export type ThemeMode = Theme | "system";
|
||||
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||
light: ["default", "canvas", "apple-light", "orca-light"],
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark", "sunset-cloud", "terminal", "night-sea"],
|
||||
light: ["default", "canvas", "apple-light", "orca-light", "sakura"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
const MODE_KEY = "theme";
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"title": "Administration",
|
||||
"description": "Accounts and the server AgentHub runs on."
|
||||
"description": "Accounts and the server AgentHub runs on.",
|
||||
"tabs": {
|
||||
"users": "Users",
|
||||
"server": "Server settings"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"status_duration": "Stuck agent",
|
||||
"token_threshold": "Token threshold",
|
||||
"cost_threshold": "Cost threshold",
|
||||
"budget": "Spend budget"
|
||||
"budget": "Spend budget",
|
||||
"remote_unmonitored": "Company account outside AgentHub"
|
||||
},
|
||||
"ruleTypeHints": {
|
||||
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
|
||||
@@ -17,7 +18,8 @@
|
||||
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
|
||||
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
|
||||
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
|
||||
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool."
|
||||
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool.",
|
||||
"remote_unmonitored": "Fires when a user's machine uses the company account for a session it did not start from AgentHub (nothing of it is sent to the hub). Server mode only."
|
||||
},
|
||||
"ruleDesc": {
|
||||
"eventPattern": "Matches {{pattern}}",
|
||||
@@ -26,7 +28,8 @@
|
||||
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
||||
"tokenThreshold": "Session tokens exceed {{tokens}}",
|
||||
"costThreshold": "Session cost reaches {{usd}}",
|
||||
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}"
|
||||
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}",
|
||||
"remoteUnmonitored": "session not started from AgentHub"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Alert rules",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"share": "Share",
|
||||
"unshare": "Unshare",
|
||||
"shareDone": "Shared to the Hub",
|
||||
"unshareDone": "Removed from the Hub",
|
||||
"unshareDone": "No longer shared",
|
||||
"reshare": "Share again",
|
||||
"reshareDone": "New version shared — people who installed it are offered Update",
|
||||
"reshareSame": "Already the newest version — shared",
|
||||
@@ -234,5 +234,50 @@
|
||||
"pack.updateN": "Update {{count}}",
|
||||
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
||||
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
||||
"pack.updated": "Updated {{ok}} · failed {{failed}}"
|
||||
"pack.updated": "Updated {{ok}} · failed {{failed}}",
|
||||
"tab.withme": "Shared with you",
|
||||
"sharing": "Sharing…",
|
||||
"shareTitle": "Share “{{title}}”",
|
||||
"shareMode.people": "Only the people I pick",
|
||||
"shareMode.peopleHint": "Only them (and admins) see it; nobody else finds it in the Hub.",
|
||||
"shareMode.everyone": "Everyone (the Hub)",
|
||||
"shareMode.everyoneHint": "Every user sees it in the Hub.",
|
||||
"shareMode.nobody": "Stop sharing",
|
||||
"shareMode.nobodyHint": "Only you (and admins) see it again.",
|
||||
"sharePeople.search": "Find a person",
|
||||
"sharePeople.none": "Nobody matches.",
|
||||
"sharePeople.picked_one": "{{count}} person picked",
|
||||
"sharePeople.picked_other": "{{count}} people picked",
|
||||
"shareSave": "Save",
|
||||
"sharePeopleDone_one": "Shared with {{count}} person",
|
||||
"sharePeopleDone_other": "Shared with {{count}} people",
|
||||
"sharedWithCount_one": "Shared with {{count}} person",
|
||||
"sharedWithCount_other": "Shared with {{count}} people",
|
||||
"sharedWithMe": "Shared with you",
|
||||
"withme.empty": "Nothing has been shared with you by name yet.",
|
||||
"folders.all": "All folders",
|
||||
"folders.new": "New folder",
|
||||
"folders.newName": "Folder name",
|
||||
"folders.rename": "Rename",
|
||||
"folders.delete": "Delete folder",
|
||||
"folders.deleteTitle": "Delete the folder “{{name}}”?",
|
||||
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
|
||||
"folders.empty": "This folder is empty. Drag an artifact onto the folder, or use ⋯ → Move to folder.",
|
||||
"folders.move": "Move to folder…",
|
||||
"folders.moveTitle": "Move “{{title}}” to a folder",
|
||||
"folders.private": "Your folders are yours alone: nobody else sees them.",
|
||||
"folders.createHere": "New folder",
|
||||
"folders.takeOut": "Take it out of its folder",
|
||||
"page.view": "View",
|
||||
"page.viewHint": "Its copy, viewable here without the claude.ai account that published it",
|
||||
"page.noCopy": "No viewable copy: the file this page was published from is gone, or lies outside this workspace and its session's scratchpad. Open it on claude.ai instead.",
|
||||
"pdf.page": "Page {{n}}",
|
||||
"pdf.failed": "This PDF could not be shown. Download it instead.",
|
||||
"pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.",
|
||||
"exportsSwitch": "Keep a viewable copy of Docs and Slides",
|
||||
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards.",
|
||||
"folders.moved": "Moved to “{{name}}”",
|
||||
"folders.takenOut": "Taken out of its folder",
|
||||
"folders.dropHint": "Drop an artifact here to put it in this folder",
|
||||
"folders.dropOutHint": "Drop an artifact here to take it out of its folder"
|
||||
}
|
||||
|
||||
@@ -206,7 +206,13 @@
|
||||
"confirmPassword": "Confirm new password",
|
||||
"mismatch": "The two new passwords differ",
|
||||
"mustChangeSubmit": "Save and continue",
|
||||
"saving": "Saving…"
|
||||
"saving": "Saving…",
|
||||
"tokenTitle": "Dashboard token",
|
||||
"tokenSubtitle": "This dashboard requires its access token (DASHBOARD_TOKEN). It is saved in this browser only.",
|
||||
"tokenWrong": "That token was not accepted — check it and try again.",
|
||||
"tokenLabel": "Token",
|
||||
"tokenHint": "On the dashboard's machine, `agenthub start` prints a link that carries it.",
|
||||
"tokenSubmit": "Continue"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
@@ -223,5 +229,9 @@
|
||||
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
||||
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"search": "Search…",
|
||||
"noMatch": "No match"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,12 @@
|
||||
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Users · CPU / RAM now",
|
||||
"usersCount": "{{n}} users"
|
||||
"usersCount": "{{n}} users",
|
||||
"userTipRole": "Role: {{v}}",
|
||||
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
|
||||
"userTipWork": "{{w}} workspaces · {{s}} sessions",
|
||||
"userTipLastLogin": "Last sign-in: {{v}}",
|
||||
"userTipNever": "never",
|
||||
"userTipDisabled": "Disabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Branch",
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
"gitBar.branchSearch": "Search branches…",
|
||||
"gitBar.switchCurrent": "current",
|
||||
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
|
||||
@@ -276,6 +277,7 @@
|
||||
"renameLabel": "Rename this workspace",
|
||||
"proxy.native": "Native ({{tool}})",
|
||||
"proxy.label": "AI Proxy",
|
||||
"proxy.company": "Company account",
|
||||
"runtime.down": "down",
|
||||
"runtime.healthy": "healthy",
|
||||
"runtime.partial": "partial",
|
||||
@@ -384,6 +386,32 @@
|
||||
"menu.copyPath": "Copy path",
|
||||
"menu.reset": "Reset worktree…",
|
||||
"menu.remove": "Delete workspace…",
|
||||
"menu.share": "Share…",
|
||||
"share.title": "Share workspace",
|
||||
"share.what": "People you share it with see this workspace and its sessions, join its open terminals, type into them and commit — like you. Only you rename, reset, delete or share it. A session they start runs on their own account and is billed to them.",
|
||||
"share.warning": "They work as YOUR account on this server: they can read everything in your home folder (your other workspaces, SSH keys and AI logins) and their commits carry your name. Share only with people you would hand your keyboard to.",
|
||||
"share.loading": "Loading…",
|
||||
"share.nobody": "There is no one else to share with.",
|
||||
"share.search": "Search people",
|
||||
"share.cancel": "Cancel",
|
||||
"share.close": "Close",
|
||||
"share.save": "Save",
|
||||
"share.saving": "Saving…",
|
||||
"share.confirmTitle": "Share \"{{name}}\"?",
|
||||
"share.confirmBody": "{{people}} will work in it as your account and can read everything in your home folder.",
|
||||
"share.confirm": "Share",
|
||||
"share.saved_one": "Shared with {{count}} person",
|
||||
"share.saved_other": "Shared with {{count}} people",
|
||||
"share.disabled": "(disabled)",
|
||||
"share.savedNone": "No longer shared with anyone",
|
||||
"shared.ownAccount": "This workspace is shared with you: a session you start here runs on your own account — a pool account or an AI Proxy of yours — and is billed to you.",
|
||||
"share.sessionTitle": "Share session",
|
||||
"share.sessionWhat": "People you share it with see this session and the workspace's changes, join its open tab, type into it, send it messages (on their own account, billed to them) and commit — nothing else of the workspace. Only you share it. On this server they work as YOUR account inside its terminal, so they can still reach the rest of your home from there.",
|
||||
"sessionMenu.share": "Share…",
|
||||
"shared.sessionOnly": "Only a session of this workspace is shared with you: open it from the list to work in it.",
|
||||
"tabs.billedTo": "Billed to {{name}}",
|
||||
"tabs.here": "Also here: {{names}}",
|
||||
"shared.badge": "Shared with you",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Preview",
|
||||
@@ -707,6 +735,7 @@
|
||||
"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}}",
|
||||
"tmuxUpgradeOptional": "tmux is older than recommended — terminals work as they are; this message shows once. {{advice}}",
|
||||
"changes.rescanRepos": "Scan for repositories again",
|
||||
"tree.newGroup": "New group",
|
||||
"tree.groupDefaultName": "New group",
|
||||
@@ -748,7 +777,7 @@
|
||||
"shelves.deleted": "Shelf deleted",
|
||||
"shelves.restored": "Shelved changes restored",
|
||||
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
|
||||
"shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf",
|
||||
"shelves.restoreDirty": "Commit, discard or unstage the changes in this shelf's files (and anything staged) before restoring it",
|
||||
"shelves.shelveTicked": "Shelve ({{count}})",
|
||||
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
|
||||
"shelves.shelveApply": "Shelve {{count}} file(s)",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"clearAlerts": "Clear alerts",
|
||||
"send": "Send",
|
||||
"rename": "Click to rename {{name}}",
|
||||
"renameLabel": "Pet name",
|
||||
"renameHint": "Enter to save · empty = default name",
|
||||
@@ -53,13 +52,15 @@
|
||||
"attention": "Needs you",
|
||||
"attentionError": "errored",
|
||||
"attentionWaiting": "waiting on you",
|
||||
"attentionMore": "+{{count}} more in Sessions"
|
||||
"attentionMore": "+{{count}} more in Sessions",
|
||||
"label": "Pet {{name}}",
|
||||
"close": "Close {{name}}",
|
||||
"status": "Status — click to show the sessions that need you"
|
||||
},
|
||||
"bubbleDismiss": "Dismiss",
|
||||
"open": "Open {{name}} companion",
|
||||
"closeAvatar": "Close {{name}}",
|
||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||
"startFailed": "Couldn't start that session: {{error}}",
|
||||
"bubbleOpen": "Open",
|
||||
"bubbleMore": "{{list}} +{{count}} more",
|
||||
"notify": {
|
||||
@@ -174,23 +175,6 @@
|
||||
"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}}",
|
||||
"close": "Close {{name}}",
|
||||
"new": "New chat",
|
||||
"status": "Status — click to show the sessions that need you",
|
||||
"endTitle": "End this chat?",
|
||||
"endBody": "It is still writing a reply. A new chat stops this session.",
|
||||
"endConfirm": "Stop and start new",
|
||||
"openWorkspace": "Open in workspace",
|
||||
"typing": "typing…",
|
||||
"placeholder": "Ask {{name}} anything…",
|
||||
"emptyTitle": "Hi! I'm {{name}} 🐾",
|
||||
"emptyHint": "Ask me anything — I'll get {{tool}} to answer right here.",
|
||||
"starting": "Starting {{tool}}…",
|
||||
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
||||
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
@@ -884,23 +868,35 @@
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
||||
"signTitle": "Commit signing",
|
||||
"signIntro": "Sign every commit — from your terminal, the Changes panel or an agent — with one of your SSH keys, so a host that requires signed commits accepts your push.",
|
||||
"signOff": "Commits are not signed.",
|
||||
"signOn": "Every commit is signed with {{key}}.",
|
||||
"signForeign": "Commits are signed with a key you set up yourself: {{path}}.",
|
||||
"signIntro": "Choose which workspaces sign their commits — from your terminal, the Changes panel or an agent — with one of your SSH keys. Every other repository stays unsigned.",
|
||||
"signLanes": "Workspaces",
|
||||
"signLanesEmpty": "You have no workspace yet.",
|
||||
"signNeedKey": "Import a key you have, or make one below, to sign with.",
|
||||
"signLaneGone": "Workspace #{{id}} (removed)",
|
||||
"signLaneKey": "Signing key for {{name}}",
|
||||
"signLaneSaved": "{{name}} now signs its commits with {{key}}.",
|
||||
"signLaneStopped": "{{name}} no longer signs its commits.",
|
||||
"signLegacy": "Every commit of your account is signed with {{key}} (the old account-wide setting).",
|
||||
"signLegacyOff": "Sign per workspace only",
|
||||
"signLegacyStopped": "Account-wide signing is off — each workspace keeps its own choice.",
|
||||
"signForeign": "Your ~/.gitconfig signs every commit with a key you set up yourself: {{path}}.",
|
||||
"signForeignRemove": "Remove",
|
||||
"signForeignConfirm": "Take {{path}} out of your ~/.gitconfig? Commits stop being signed with it; the key file itself is not deleted.",
|
||||
"signForeignRemoved": "The old signing setup was taken out of ~/.gitconfig.",
|
||||
"signKeys": "Keys you can sign with",
|
||||
"signKeyUsed_one": "signs {{count}} workspace",
|
||||
"signKeyUsed_other": "signs {{count}} workspaces",
|
||||
"signKeyCopy": "Copy the public key of {{key}}",
|
||||
"signKeyDeleteConfirm": "Delete key {{key}}? Its private file is deleted, every workspace signing with it stops signing, and clones that use it can no longer fetch or push.",
|
||||
"signKeyDeleted": "Key {{key}} deleted.",
|
||||
"signKey": "Signing key",
|
||||
"signNone": "Don't sign",
|
||||
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
|
||||
"signReplaceConfirm": "Your git already signs with {{path}}. Switch to {{key}}?",
|
||||
"signReplace": "Switch",
|
||||
"signSaved": "Commits are now signed with {{key}}.",
|
||||
"signStopped": "Commits are no longer signed.",
|
||||
"importKey": "Import a key you have",
|
||||
"importTitle": "Import an existing private key",
|
||||
"importFile": "Choose the key file",
|
||||
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
|
||||
"importName": "Name (optional)",
|
||||
"importSign": "Sign my commits with it",
|
||||
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
|
||||
"importSubmit": "Import",
|
||||
"imported": "Key {{name}} imported.",
|
||||
@@ -1117,7 +1113,8 @@
|
||||
}
|
||||
},
|
||||
"backPrevious": "Back",
|
||||
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead."
|
||||
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead.",
|
||||
"movedToAdmin": "Settings that apply to the whole server (laptop connections, tests on this server, git logins, reports, model pricing, data…) are in Administration → Server settings."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search settings",
|
||||
@@ -1157,6 +1154,11 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"memberPolicy": {
|
||||
"title": "Tests on this server",
|
||||
"switch": "Allow members to run tests",
|
||||
"hint": "Off: members' Claude may not run test runners or container commands here (vitest, jest, phpunit, pytest, npm test, docker, …) — a few at once freeze this shared server, so they test on their own machine. On: they may. Commands typed by hand in a terminal and Codex are not checked either way."
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Oh My Pi login sharing",
|
||||
"switch": "Share this server's Oh My Pi login",
|
||||
@@ -1437,17 +1439,72 @@
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Pool account",
|
||||
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
|
||||
"automatic": "Automatic",
|
||||
"select": "{{tool}} pool account",
|
||||
"count_one": "{{count}} account you may use",
|
||||
"count_other": "{{count}} accounts you may use",
|
||||
"pinned": "An admin pinned you to {{account}}",
|
||||
"expired": "expired",
|
||||
"full": "quota full",
|
||||
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||
"nextSession": "{{account}} is used from your next session"
|
||||
"nextSession": "{{account}} is used from your next session",
|
||||
"inUse": "in use",
|
||||
"badge": "Pool",
|
||||
"automaticHint": "Your new sessions run on the least-used pool account",
|
||||
"quota": "Quota {{pct}}%"
|
||||
},
|
||||
"companyHub": {
|
||||
"title": "Company hub",
|
||||
"description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.",
|
||||
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first. Your folder paths, account name and machine name are replaced with ~ and device before sending.",
|
||||
"ruleNotSent": "Sessions on your own login or your own AI Proxy send nothing — not even the folder name. The hub never reads your files and never controls this machine.",
|
||||
"ruleKey": "The company account's credential stays on the hub; this machine keeps only a device token (90 days, revocable).",
|
||||
"ruleOffice": "The hub is on the company network: away from it, Company-account sessions cannot start; your own login works as usual.",
|
||||
"enrolledAs": "Enrolled with {{hub}} as {{user}}",
|
||||
"validUntil": "valid until {{time}}",
|
||||
"expired": "expired",
|
||||
"expiredHint": "This machine's hub login expired — enroll again.",
|
||||
"sendNow": "Send now",
|
||||
"logout": "Log out",
|
||||
"logoutTitle": "Log this machine out of the hub?",
|
||||
"logoutHint": "Its device token is revoked on the hub. Company-account sessions stop working here until you enroll again.",
|
||||
"sentTitle": "Sent to the hub",
|
||||
"sentLine": "{{bytes}} · {{time}}",
|
||||
"notSentYet": "not sent yet",
|
||||
"ended": "ended",
|
||||
"nothingSent": "No Company-account session yet.",
|
||||
"sentToast": "Sent {{bytes}} of {{sessions}} session(s)",
|
||||
"enrolledToast": "This machine is enrolled with the hub",
|
||||
"hubAddress": "Hub address",
|
||||
"username": "Hub username",
|
||||
"password": "Hub password",
|
||||
"enroll": "Enroll this machine",
|
||||
"cliHint": "Or in a terminal: agenthub remote login <hub address>",
|
||||
"hubTitle": "Your machines",
|
||||
"hubDescription": "Machines running AgentHub local that you enrolled with this hub, and their Company-account sessions.",
|
||||
"noDevices": "No machine enrolled.",
|
||||
"revokedAt": "revoked {{time}}",
|
||||
"lastSeen": "seen {{time}}",
|
||||
"revoke": "Revoke",
|
||||
"revokeTitle": "Revoke {{name}}?",
|
||||
"revokeHint": "Its device token stops working at once; the machine must enroll again.",
|
||||
"revoked": "{{name}} revoked",
|
||||
"sessions": "Company-account sessions",
|
||||
"unmonitored": "not started from AgentHub",
|
||||
"sessionLine": "{{requests}} requests · {{input}} in · {{output}} out · {{account}}",
|
||||
"purged": "conversation removed (retention)",
|
||||
"whoRead": "Who opened your sessions",
|
||||
"nobodyRead": "Nobody has opened your sessions.",
|
||||
"policyTitle": "Allow laptop connections",
|
||||
"policyOffHint": "Off: AgentHub is used on the server only. Laptops cannot enroll, and enrolled laptops can neither use company accounts nor send anything here.",
|
||||
"policyOnHint": "On: members may run agents on their laptops with company accounts through this hub. That laptop's OS, the paths in its prompts and its device id still reach Anthropic.",
|
||||
"policyOnTitle": "Turn laptop connections on?",
|
||||
"policyOffTitle": "Turn laptop connections off?",
|
||||
"policyOnConfirm": "Members will be able to use company accounts from their own laptops. Prompts from a laptop carry that laptop's details (OS, working folder).",
|
||||
"policyOffConfirm": "Every enrolled laptop stops using company accounts at once; sessions running on a laptop will fail. Their own accounts keep working.",
|
||||
"policyOnAction": "Turn on",
|
||||
"policyOffAction": "Turn off",
|
||||
"policyOnToast": "Laptop connections turned on",
|
||||
"policyOffToast": "Laptop connections turned off",
|
||||
"adminTitle": "Company hub",
|
||||
"adminDescription": "Whether laptops running AgentHub local may connect to this server, and every machine people enrolled."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,6 @@
|
||||
"noOwner": "no owner",
|
||||
"ownedBy": "owned by {{name}}",
|
||||
"noWorkspaces": "There are no workspaces yet.",
|
||||
"yourPassword": "Your password",
|
||||
"yourPasswordHint": "Type your own password to confirm this change.",
|
||||
"save": "Save",
|
||||
"dialog": {
|
||||
"add": "Add a user",
|
||||
@@ -293,7 +291,7 @@
|
||||
"noneShown": "No member matches."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Nothing is saved until Save.",
|
||||
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Every change saves itself.",
|
||||
"sharedHint": "Members and accounts in no pool",
|
||||
"toShared": "Send {{name}} back to the shared pool",
|
||||
"removeAccount": "Send {{name}} back to the shared pool",
|
||||
@@ -303,33 +301,27 @@
|
||||
"noSharedAccounts": "No account is left for the shared pool.",
|
||||
"dropMembers": "Drop members here",
|
||||
"untitled": "New pool",
|
||||
"new": "not saved yet",
|
||||
"new": "saving",
|
||||
"pick": "Pick members of {{name}}",
|
||||
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Applied on the board — Save sends it.",
|
||||
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Saved when you Apply.",
|
||||
"apply": "Apply",
|
||||
"addAccount": "Move an account into {{name}}",
|
||||
"addAccountOption": "+ Move an account or saved profile here…",
|
||||
"profilesOf": "Saved {{tool}} profiles — moved into the pool on Save",
|
||||
"profilePending": "A saved profile: it is moved into the pool when you save",
|
||||
"pending_one": "{{count}} unsaved change",
|
||||
"pending_other": "{{count}} unsaved changes",
|
||||
"profilesOf": "Saved {{tool}} profiles — moved into the pool at once",
|
||||
"profilePending": "A saved profile: being moved into the pool",
|
||||
"needsName": "Every pool needs a name.",
|
||||
"discard": "Discard",
|
||||
"saveTitle": "Save the pools",
|
||||
"saveHint": "Type your own password once to apply every change below.",
|
||||
"summaryProfile": "Move the saved profile {{name}} into the pool",
|
||||
"summaryNew": "Create {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"summaryGroup": "Set {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"saved": "Pools saved",
|
||||
"dropAccounts": "Drag accounts here",
|
||||
"noAccounts": "No accounts",
|
||||
"accountsCount_one": "{{count}} account",
|
||||
"accountsCount_other": "{{count}} accounts",
|
||||
"mergeHint": "Drop to merge {{from}} into {{into}}",
|
||||
"merged": "{{from}} merged into {{into}} — Save to apply",
|
||||
"summaryMerge": "Merge {{from}} into {{into}}: pool {{from}} is deleted",
|
||||
"merged": "{{from}} merged into {{into}}",
|
||||
"newDrop": "Drop here to split it into a pool of its own",
|
||||
"newHint": "Or drag an account or a member here to split it off"
|
||||
"newHint": "Or drag an account or a member here to split it off",
|
||||
"defaultName": "Pool {{n}}",
|
||||
"saving": "Saving…",
|
||||
"saveFailed": "Not saved: {{error}}",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"addAccount": "Add account",
|
||||
"otherTools": "Other AI tools"
|
||||
@@ -338,8 +330,7 @@
|
||||
"button": "Delete user",
|
||||
"title": "Delete {{name}}?",
|
||||
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
|
||||
"confirm": "Delete for good",
|
||||
"needsPassword": "Enter your own password below first."
|
||||
"confirm": "Delete for good"
|
||||
},
|
||||
"tmux": {
|
||||
"title": "Members' tmux",
|
||||
@@ -409,7 +400,8 @@
|
||||
"activeLabel": "Running now",
|
||||
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
||||
"nowTitle": "Running now",
|
||||
"poolTheirs7d": "theirs 7 d {{cost}}"
|
||||
"poolTheirs7d": "theirs 7 d {{cost}}",
|
||||
"devicesTitle": "Machines (AgentHub local)"
|
||||
},
|
||||
"activity": "Activity",
|
||||
"spend": "Spend today",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"title": "Quản trị",
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy."
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
|
||||
"tabs": {
|
||||
"users": "Người dùng",
|
||||
"server": "Cài đặt máy chủ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"status_duration": "Agent bị kẹt",
|
||||
"token_threshold": "Ngưỡng token",
|
||||
"cost_threshold": "Ngưỡng chi phí",
|
||||
"budget": "Ngân sách chi tiêu"
|
||||
"budget": "Ngân sách chi tiêu",
|
||||
"remote_unmonitored": "Tài khoản công ty dùng ngoài AgentHub"
|
||||
},
|
||||
"ruleTypeHints": {
|
||||
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
|
||||
@@ -17,7 +18,8 @@
|
||||
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
|
||||
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
|
||||
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
|
||||
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI."
|
||||
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI.",
|
||||
"remote_unmonitored": "Kích hoạt khi máy của một người dùng tài khoản công ty cho một phiên không mở từ AgentHub (phiên đó không được gửi về hub). Chỉ có ở chế độ server."
|
||||
},
|
||||
"ruleDesc": {
|
||||
"eventPattern": "Khớp {{pattern}}",
|
||||
@@ -26,7 +28,8 @@
|
||||
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
|
||||
"tokenThreshold": "Token của phiên vượt {{tokens}}",
|
||||
"costThreshold": "Chi phí phiên chạm {{usd}}",
|
||||
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}"
|
||||
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}",
|
||||
"remoteUnmonitored": "phiên không mở từ AgentHub"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Quy tắc cảnh báo",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"share": "Chia sẻ",
|
||||
"unshare": "Bỏ chia sẻ",
|
||||
"shareDone": "Đã chia sẻ lên Hub",
|
||||
"unshareDone": "Đã gỡ khỏi Hub",
|
||||
"unshareDone": "Đã ngừng chia sẻ",
|
||||
"reshare": "Share lại",
|
||||
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
||||
"reshareSame": "Đã là bản mới nhất — đã share",
|
||||
@@ -232,5 +232,50 @@
|
||||
"pack.updateN": "Cập nhật {{count}}",
|
||||
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
||||
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
||||
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
|
||||
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}",
|
||||
"tab.withme": "Chia sẻ với bạn",
|
||||
"sharing": "Chia sẻ…",
|
||||
"shareTitle": "Chia sẻ “{{title}}”",
|
||||
"shareMode.people": "Chỉ những người tôi chọn",
|
||||
"shareMode.peopleHint": "Chỉ họ (và admin) thấy; người khác không thấy trên Hub.",
|
||||
"shareMode.everyone": "Mọi người (Hub)",
|
||||
"shareMode.everyoneHint": "Mọi người dùng đều thấy trên Hub.",
|
||||
"shareMode.nobody": "Ngừng chia sẻ",
|
||||
"shareMode.nobodyHint": "Chỉ bạn (và admin) thấy như trước.",
|
||||
"sharePeople.search": "Tìm người",
|
||||
"sharePeople.none": "Không có ai khớp.",
|
||||
"sharePeople.picked_one": "Đã chọn {{count}} người",
|
||||
"sharePeople.picked_other": "Đã chọn {{count}} người",
|
||||
"shareSave": "Lưu",
|
||||
"sharePeopleDone_one": "Đã chia sẻ với {{count}} người",
|
||||
"sharePeopleDone_other": "Đã chia sẻ với {{count}} người",
|
||||
"sharedWithCount_one": "Chia sẻ với {{count}} người",
|
||||
"sharedWithCount_other": "Chia sẻ với {{count}} người",
|
||||
"sharedWithMe": "Chia sẻ với bạn",
|
||||
"withme.empty": "Chưa có ai chia sẻ riêng với bạn.",
|
||||
"folders.all": "Tất cả thư mục",
|
||||
"folders.new": "Thư mục mới",
|
||||
"folders.newName": "Tên thư mục",
|
||||
"folders.rename": "Đổi tên",
|
||||
"folders.delete": "Xóa thư mục",
|
||||
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
|
||||
"folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.",
|
||||
"folders.empty": "Thư mục này trống. Kéo artifact thả vào thư mục, hoặc dùng ⋯ → Chuyển vào thư mục.",
|
||||
"folders.move": "Chuyển vào thư mục…",
|
||||
"folders.moveTitle": "Chuyển “{{title}}” vào thư mục",
|
||||
"folders.private": "Thư mục là của riêng bạn: người khác không thấy.",
|
||||
"folders.createHere": "Thư mục mới",
|
||||
"folders.takeOut": "Bỏ khỏi thư mục",
|
||||
"page.view": "Xem",
|
||||
"page.viewHint": "Bản sao xem được ngay tại đây, không cần tài khoản claude.ai đã tạo trang",
|
||||
"page.noCopy": "Chưa có bản xem: file gốc của trang này không còn, hoặc nằm ngoài workspace và scratchpad của phiên. Hãy mở trên claude.ai.",
|
||||
"pdf.page": "Trang {{n}}",
|
||||
"pdf.failed": "Không hiển thị được PDF này. Hãy tải về.",
|
||||
"pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.",
|
||||
"exportsSwitch": "Giữ bản xem được của Docs và Slides",
|
||||
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó.",
|
||||
"folders.moved": "Đã chuyển vào “{{name}}”",
|
||||
"folders.takenOut": "Đã bỏ khỏi thư mục",
|
||||
"folders.dropHint": "Thả artifact vào đây để đưa vào thư mục này",
|
||||
"folders.dropOutHint": "Thả artifact vào đây để bỏ khỏi thư mục"
|
||||
}
|
||||
|
||||
@@ -206,7 +206,13 @@
|
||||
"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…"
|
||||
"saving": "Đang lưu…",
|
||||
"tokenTitle": "Token của dashboard",
|
||||
"tokenSubtitle": "Dashboard này yêu cầu token truy cập (DASHBOARD_TOKEN). Token chỉ được lưu trong trình duyệt này.",
|
||||
"tokenWrong": "Token không đúng — kiểm tra lại rồi thử lần nữa.",
|
||||
"tokenLabel": "Token",
|
||||
"tokenHint": "Trên máy chạy dashboard, lệnh `agenthub start` in sẵn một đường link có kèm token.",
|
||||
"tokenSubmit": "Tiếp tục"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
@@ -223,5 +229,9 @@
|
||||
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
|
||||
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"search": "Tìm…",
|
||||
"noMatch": "Không có kết quả"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,12 @@
|
||||
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
||||
"usersCount": "{{n}} người dùng"
|
||||
"usersCount": "{{n}} người dùng",
|
||||
"userTipRole": "Vai trò: {{v}}",
|
||||
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
|
||||
"userTipWork": "{{w}} workspace · {{s}} phiên",
|
||||
"userTipLastLogin": "Đăng nhập gần nhất: {{v}}",
|
||||
"userTipNever": "chưa bao giờ",
|
||||
"userTipDisabled": "Đã vô hiệu hóa"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user