Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb65dbe795 | |||
| 0df2594f19 | |||
| 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 | |||
| 246b554dab | |||
| 7ee79292fa | |||
| 600e8eec6a | |||
| 3ec1978176 | |||
| 12ea853170 | |||
| 7e41c81d53 | |||
| 2d6c751fb2 | |||
| e0b8d5641e | |||
| faa96d1964 | |||
| 68c3cfaf26 | |||
| 1ad2fec073 | |||
| 1294ce1faa | |||
| 5d62003fe5 | |||
| 12d53afaeb | |||
| 4263b3eb26 | |||
| d58f13e1ce | |||
| 81b3cbda65 | |||
| 0a99eed369 | |||
| 1ec9f2e592 | |||
| a401a318c5 | |||
| 5caad0df2d | |||
| 01e66ba122 | |||
| 923c1635b4 | |||
| c37bc5205b |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,6 +287,11 @@ function UploadChip({ job }: { job: UploadJob }) {
|
||||
{isArmed("stop") && !stopping && (
|
||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||
)}
|
||||
{job.waiting && !stopping && (
|
||||
<p className="text-[11px] text-status-warning" role="status" data-testid="upload-waiting">
|
||||
{t("uploads.waiting")}
|
||||
</p>
|
||||
)}
|
||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||
{job.folder}
|
||||
{job.name ? ` · ${job.name}` : ""}
|
||||
|
||||
@@ -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
|
||||
@@ -63,11 +65,12 @@
|
||||
|
||||
import React from "react";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
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" }
|
||||
| {
|
||||
@@ -195,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;
|
||||
}
|
||||
|
||||
@@ -305,7 +316,13 @@ const LABEL_CLASS = (word: string) =>
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, url)}
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
@@ -466,6 +483,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, href)}
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
@@ -516,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";
|
||||
@@ -586,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
|
||||
@@ -865,7 +865,7 @@ function MessageListView({
|
||||
className={
|
||||
compact
|
||||
? "space-y-3 py-1 pr-2"
|
||||
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
|
||||
: "mx-auto flex w-full max-w-5xl flex-col gap-3 px-4 py-6"
|
||||
}
|
||||
>
|
||||
{items.map((item) => {
|
||||
@@ -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,
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
type FsPlace,
|
||||
type GitSource,
|
||||
isHomePlace,
|
||||
newUploadId,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
@@ -529,7 +530,13 @@ export function FileBrowser({
|
||||
await fn();
|
||||
if (done) toast.success(done);
|
||||
} catch (e) {
|
||||
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
|
||||
toast.error(
|
||||
errCode(e) === "EUPGRADE"
|
||||
? t("fx.needsUpgrade")
|
||||
: errCode(e) === "ESECRET"
|
||||
? t("fx.secret")
|
||||
: errText(e)
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
changed();
|
||||
@@ -682,13 +689,21 @@ export function FileBrowser({
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||
const onWait = (waiting: boolean) => job.update({ waiting });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
if (signal.aborted) return false;
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
// One id for every attempt: a retry with another `conflict` reuses the staged bytes.
|
||||
const upload = newUploadId();
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
|
||||
api.files.uploadStaged(place, dest, file, c, {
|
||||
upload,
|
||||
onProgress: (n) => show(rel, n),
|
||||
onWait,
|
||||
signal,
|
||||
});
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -701,6 +716,9 @@ export function FileBrowser({
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
} finally {
|
||||
onWait(false);
|
||||
void api.files.dropUpload(place, upload);
|
||||
}
|
||||
sent += file.size;
|
||||
done++;
|
||||
@@ -720,6 +738,7 @@ export function FileBrowser({
|
||||
return a.choice;
|
||||
};
|
||||
if (signal.aborted) return "cancel";
|
||||
const upload = newUploadId();
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
place,
|
||||
@@ -733,7 +752,12 @@ export function FileBrowser({
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
|
||||
api.files.uploadBatchStaged(place, dir, b, p, {
|
||||
upload,
|
||||
onProgress: (n) => show(label, n),
|
||||
onWait,
|
||||
signal,
|
||||
});
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -752,6 +776,9 @@ export function FileBrowser({
|
||||
return "fallback";
|
||||
}
|
||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||
} finally {
|
||||
onWait(false);
|
||||
void api.files.dropUpload(place, upload);
|
||||
}
|
||||
sent += size;
|
||||
done += b.length;
|
||||
@@ -1693,7 +1720,6 @@ export function FileBrowser({
|
||||
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
|
||||
@@ -489,8 +489,9 @@ function FileActions({
|
||||
title={t("changes.discardConfirm")}
|
||||
disabled={busy}
|
||||
onClick={() => onAct("discard", f.path, true)}
|
||||
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("changes.discardShortConfirm")}
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -373,12 +373,17 @@ export function LaneGitHeader({
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
{/* A long branch name truncates too (after the subject has gone),
|
||||
or it overflowed onto the head and the action icons. */}
|
||||
<span
|
||||
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
|
||||
title={git.branch}
|
||||
>
|
||||
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{git.branch}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}`))}>
|
||||
|
||||
@@ -145,7 +145,7 @@ export function SessionReplay({
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||
<button
|
||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||
<button
|
||||
@@ -172,7 +172,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file UrlPreview.tsx
|
||||
* @description The session panel's Preview tab: a web page (a lane's dev
|
||||
* server, mostly) shown beside the console. One bar — address, Reload, Open
|
||||
* in a browser tab — and, before anything is open, the workspace's own ports
|
||||
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
|
||||
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
|
||||
* http(s), and the sandbox leaves out top navigation so a page cannot move
|
||||
* the dashboard away. A site that forbids framing (most login pages) shows
|
||||
* blank or as the browser's broken-page icon; that is the browser's rule and
|
||||
* cannot be read from here, so any page not on this machine gets a strip
|
||||
* saying so with the Open button beside it. The
|
||||
* last address is remembered per workspace in localStorage (a viewer
|
||||
* convenience, never the server's).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Globe, RotateCw } from "lucide-react";
|
||||
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
|
||||
|
||||
function readSaved(laneId: number): string {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY(laneId)) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** The frame's sandbox: everything a web app needs, minus moving the
|
||||
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
|
||||
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
|
||||
const FRAME_SANDBOX =
|
||||
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
|
||||
|
||||
export function UrlPreview({
|
||||
laneId,
|
||||
ports,
|
||||
request,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** The lane's bound ports, by name — the chips of the empty state. */
|
||||
ports: Record<string, number>;
|
||||
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
|
||||
request: { url: string; nonce: number } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [url, setUrl] = useState(() => readSaved(laneId));
|
||||
const [draft, setDraft] = useState(url);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
// Another workspace: its own last page.
|
||||
useEffect(() => {
|
||||
const saved = readSaved(laneId);
|
||||
setUrl(saved);
|
||||
setDraft(saved);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) go(request.url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
|
||||
}, [request?.nonce]);
|
||||
|
||||
function go(raw: string) {
|
||||
const next = normalizePreviewUrl(raw);
|
||||
if (!next) {
|
||||
toast.error(t("preview.badUrl"));
|
||||
return;
|
||||
}
|
||||
setUrl(next);
|
||||
setDraft(next);
|
||||
setReload((n) => n + 1);
|
||||
try {
|
||||
window.localStorage.setItem(KEY(laneId), next);
|
||||
} catch {
|
||||
/* disabled storage: the tab just forgets between visits */
|
||||
}
|
||||
}
|
||||
|
||||
const openTab = () => url && window.open(url, "_blank", "noopener,noreferrer");
|
||||
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
|
||||
const btn =
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
go(draft);
|
||||
}
|
||||
}}
|
||||
placeholder="localhost:3000"
|
||||
aria-label={t("preview.address")}
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={() => setReload((n) => n + 1)}
|
||||
title={t("preview.reload")}
|
||||
aria-label={t("preview.reload")}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={openTab}
|
||||
title={t("preview.openTab")}
|
||||
aria-label={t("preview.openTab")}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{url && !isLoopbackUrl(url) && (
|
||||
// An outside site: most refuse to be framed, and a refusal cannot be
|
||||
// detected from here — so say it, with the way out beside it.
|
||||
<div
|
||||
data-testid="url-preview-blocked-hint"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-2 px-2 py-1.5 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("preview.blockedHint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTab}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-accent hover:bg-surface-4"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("preview.openTabShort")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{url ? (
|
||||
<iframe
|
||||
key={`${url}#${reload}`}
|
||||
src={url}
|
||||
title={t("preview.title")}
|
||||
sandbox={FRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
data-testid="url-preview-frame"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Globe className="h-6 w-6 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
|
||||
{portList.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{portList.map(([name, port]) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => go(`localhost:${port}`)}
|
||||
title={name}
|
||||
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
localhost:{port}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||
source ? { lane, source } : session ? { lane, session } : lane,
|
||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||
newUploadId: (() => {
|
||||
let n = 0;
|
||||
return () => `upload-id-${String(++n).padStart(8, "0")}`;
|
||||
})(),
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
@@ -52,8 +56,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
allTrash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadBatch: vi.fn(),
|
||||
uploadStaged: vi.fn(),
|
||||
uploadBatchStaged: vi.fn(),
|
||||
dropUpload: vi.fn(async () => null),
|
||||
uploadCheck: vi.fn(),
|
||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||
},
|
||||
@@ -236,7 +241,7 @@ describe("FileExplorer", () => {
|
||||
});
|
||||
|
||||
it("asks about a taken name on upload, and sends the choice", async () => {
|
||||
vi.mocked(api.files.upload)
|
||||
vi.mocked(api.files.uploadStaged)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
||||
)
|
||||
@@ -249,20 +254,24 @@ describe("FileExplorer", () => {
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.upload).toHaveBeenLastCalledWith(
|
||||
expect(api.files.uploadStaged).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||
)
|
||||
);
|
||||
// The retry names the SAME upload, so the staged bytes are not sent again,
|
||||
// and the staged upload is dropped once answered.
|
||||
const [first, second] = vi.mocked(api.files.uploadStaged).mock.calls.map((c) => c[4].upload);
|
||||
expect(second).toBe(first);
|
||||
await waitFor(() => expect(api.files.dropUpload).toHaveBeenCalledWith(1, first));
|
||||
});
|
||||
|
||||
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||
vi.mocked(api.files.uploadBatchStaged).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
@@ -271,33 +280,32 @@ describe("FileExplorer", () => {
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
||||
expect(api.files.uploadBatchStaged).toHaveBeenCalledWith(
|
||||
1,
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const mk = (rel: string) => {
|
||||
@@ -307,8 +315,8 @@ describe("FileExplorer", () => {
|
||||
};
|
||||
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
||||
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.uploadStaged).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -320,7 +328,7 @@ describe("FileExplorer", () => {
|
||||
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
||||
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores from the Trash", async () => {
|
||||
|
||||
@@ -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,59 @@
|
||||
/**
|
||||
* @file UrlPreview.test.tsx
|
||||
* @description The Preview tab: port chips before a page is open, the
|
||||
* address bar opening a page in a sandboxed frame without top navigation,
|
||||
* a link's request opening its page, the dashboard's own origin refused, and
|
||||
* the framing-refusal strip shown for an outside site only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { UrlPreview } from "../UrlPreview";
|
||||
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
describe("UrlPreview", () => {
|
||||
it("offers the workspace's ports, and a chip opens that page", () => {
|
||||
render(<UrlPreview laneId={1} ports={{ web: 3001 }} request={null} />);
|
||||
fireEvent.click(screen.getByText("localhost:3001"));
|
||||
const frame = screen.getByTestId("url-preview-frame");
|
||||
expect(frame.getAttribute("src")).toBe("http://localhost:3001/");
|
||||
expect(frame.getAttribute("sandbox")).not.toMatch(/top-navigation/);
|
||||
expect(window.localStorage.getItem("agenthub.preview.url.1")).toBe("http://localhost:3001/");
|
||||
});
|
||||
|
||||
it("opens what the address bar names, and refuses the dashboard itself", () => {
|
||||
render(<UrlPreview laneId={2} ports={{}} request={null} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: window.location.origin + "/settings" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.queryByTestId("url-preview-frame")).toBeNull();
|
||||
fireEvent.change(input, { target: { value: "127.0.0.1:8080/app" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||
"http://127.0.0.1:8080/app"
|
||||
);
|
||||
});
|
||||
|
||||
it("warns that an outside site may refuse to be framed, never for a local page", () => {
|
||||
render(<UrlPreview laneId={4} ports={{}} request={null} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "localhost:3000" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.queryByTestId("url-preview-blocked-hint")).toBeNull();
|
||||
fireEvent.change(input, { target: { value: "https://youtube.com" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("url-preview-blocked-hint")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a link's request", () => {
|
||||
render(
|
||||
<UrlPreview laneId={3} ports={{}} request={{ url: "http://localhost:5173/", nonce: 1 }} />
|
||||
);
|
||||
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||
"http://localhost:5173/"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
||||
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
||||
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
||||
* and Manage opens the Claude config page on that tab.
|
||||
* and Manage opens the Claude config page on that tab. The menu opens
|
||||
* downward when the box sits too near the top to fit it above.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
|
||||
}
|
||||
|
||||
const PANEL =
|
||||
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
/** Room the menu and its tallest submenu (Project files: search + list) need. */
|
||||
const MENU_ROOM = 380;
|
||||
|
||||
/** The visible band the button sits in: the viewport, narrowed by any
|
||||
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
|
||||
function clipBand(el: HTMLElement): { top: number; bottom: number } {
|
||||
let top = 0;
|
||||
let bottom = window.innerHeight;
|
||||
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||
const o = getComputedStyle(p);
|
||||
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
|
||||
const r = p.getBoundingClientRect();
|
||||
top = Math.max(top, r.top);
|
||||
bottom = Math.min(bottom, r.bottom);
|
||||
}
|
||||
}
|
||||
return { top, bottom };
|
||||
}
|
||||
|
||||
/** Open downward when there is not room above but more below. */
|
||||
function opensDown(el: HTMLElement | null): boolean {
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
const band = clipBand(el);
|
||||
const above = r.top - band.top;
|
||||
const below = band.bottom - r.bottom;
|
||||
return above < MENU_ROOM && below > above;
|
||||
}
|
||||
|
||||
const ROW =
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
|
||||
@@ -195,6 +225,7 @@ export function ChatAddMenu({
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [down, setDown] = useState(false);
|
||||
const [sub, setSub] = useState<Sub | null>(null);
|
||||
const [context, setContext] = useState<Attachment[] | null>(null);
|
||||
const [recent, setRecent] = useState<Attachment[]>([]);
|
||||
@@ -286,6 +317,8 @@ export function ChatAddMenu({
|
||||
/>
|
||||
))
|
||||
);
|
||||
// A submenu lines up with the menu's edge nearest the button.
|
||||
const edge = down ? "top-0" : "bottom-0";
|
||||
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
||||
|
||||
return (
|
||||
@@ -296,13 +329,17 @@ export function ChatAddMenu({
|
||||
aria-label={t("chat.add")}
|
||||
title={t("chat.add")}
|
||||
aria-expanded={open}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
onClick={() => {
|
||||
if (open) return close();
|
||||
setDown(opensDown(ref.current));
|
||||
setOpen(true);
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
|
||||
<Row
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
label={t("chat.upload")}
|
||||
@@ -376,13 +413,13 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "project" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
|
||||
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sub === "context" || sub === "recent") && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{(sub === "context" ? context : recent)?.length ? (
|
||||
(sub === "context" ? context! : recent).map((it) => (
|
||||
@@ -405,7 +442,7 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "skills" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Row
|
||||
@@ -417,7 +454,7 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "plugins" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{lists && lists.plugins.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
||||
@@ -443,7 +480,7 @@ export function ChatAddMenu({
|
||||
onClick={() => manage("plugins")}
|
||||
/>
|
||||
{shownPlugin && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{entries(
|
||||
shownPlugin.items,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
* or an installed, enabled AI tool started bare on its default model, no
|
||||
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* or an installed, enabled AI tool started bare on its default model and
|
||||
* the account's Session defaults (permission mode, Codex sandbox/approval/
|
||||
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type GroupsUpdate,
|
||||
} from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
type PaneGroups,
|
||||
MAX_PANES,
|
||||
@@ -83,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>;
|
||||
|
||||
@@ -140,15 +144,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** The start body of a bare tool on its default model, no prompt. Only a
|
||||
* different tool is named, as the home does: the workspace's own keeps
|
||||
* starting even once disabled. */
|
||||
/** The start body of a bare tool on its default model, no prompt, in the
|
||||
* account's Session defaults (Claude's permission mode, Codex's sandbox,
|
||||
* approval and bypass) — the same ones the workspace home's prompt box starts
|
||||
* from. Only a different tool is named, as the home does: the workspace's own
|
||||
* keeps starting even once disabled. */
|
||||
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
|
||||
const model = aiTools.byId(id)?.defaultModel;
|
||||
const codex = id === "codex" ? readCodexDefaults() : null;
|
||||
return {
|
||||
prompt: "",
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
|
||||
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,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[]>(
|
||||
@@ -524,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 (
|
||||
@@ -598,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),
|
||||
@@ -609,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">
|
||||
@@ -619,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]);
|
||||
@@ -689,8 +727,11 @@ export const PaneChat = memo(function PaneChat({
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working || waiting ? 5000 : undefined,
|
||||
// The transcript growing re-reads it too: a hook that reached another
|
||||
// dashboard on the same database sends this one no agent_updated, and
|
||||
// without a poll Stop never showed for a turn the chat calls Thinking….
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
|
||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||
});
|
||||
|
||||
@@ -711,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) => {
|
||||
@@ -759,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(
|
||||
@@ -809,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));
|
||||
@@ -855,7 +909,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
{menu && (
|
||||
<PaneMenuCard
|
||||
// A new question starts with its field closed.
|
||||
|
||||
@@ -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,10 +86,12 @@ import {
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
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";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
import { openInPreview } from "../../lib/openInPreview";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
||||
|
||||
@@ -453,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();
|
||||
@@ -462,7 +466,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
searchRef.current = search;
|
||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((_e, uri) => {
|
||||
new WebLinksAddon((e, uri) => {
|
||||
// A local page opens in the session panel's Preview (one rule
|
||||
// for every link, `lib/openInPreview.ts`); Ctrl/Cmd keeps a tab.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.shiftKey && openInPreview(uri)) return;
|
||||
window.open(uri, "_blank", "noopener,noreferrer");
|
||||
})
|
||||
);
|
||||
@@ -520,9 +527,38 @@ export const TerminalView = memo(function TerminalView({
|
||||
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
||||
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
||||
let ownWheel = false;
|
||||
// Tracked: the reports are this pane's own too — one per
|
||||
// LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm
|
||||
// sends one per event and damps a touchpad ×0.3. SGR, as tmux always
|
||||
// asks for it; at the pointer's cell, with xterm's modifier bits.
|
||||
const wheelState: WheelState = { partial: 0 };
|
||||
const sendWheel = (ev: WheelEvent) => {
|
||||
const screen = term.element?.querySelector(".xterm-screen");
|
||||
const box = screen?.getBoundingClientRect();
|
||||
if (!box || !box.height || !box.width) return;
|
||||
const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows);
|
||||
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 button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
||||
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
||||
};
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
const mode = term.modes.mouseTrackingMode;
|
||||
const tracked = mode !== "none" && mode !== "x10";
|
||||
// Shift+wheel stays xterm's (it reports nothing for it).
|
||||
if (tracked && !ev.shiftKey) {
|
||||
sendWheel(ev);
|
||||
return false;
|
||||
}
|
||||
if (!tracked && ownWheel) {
|
||||
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||
@@ -598,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;
|
||||
@@ -681,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", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||
@@ -42,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),
|
||||
@@ -235,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.
|
||||
@@ -326,13 +355,36 @@ describe("TerminalView", () => {
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the wheel through once the pane's program tracks the mouse", () => {
|
||||
// tmux with `mouse on` sets this; the wheel must reach it as a real SGR
|
||||
// report, so returning false here would kill scrolling entirely.
|
||||
/** 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 screen = document.createElement("div");
|
||||
screen.className = "xterm-screen";
|
||||
screen.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
|
||||
term.element.appendChild(screen);
|
||||
};
|
||||
|
||||
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
|
||||
// tmux with `mouse on` sets this. xterm would send ONE report per event and
|
||||
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
|
||||
termState.modes.mouseTrackingMode = "any";
|
||||
termState.buffer.active.type = "alternate";
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
|
||||
screenBox();
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||||
ws.sent.length = 0;
|
||||
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
|
||||
// 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: 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 < 6; i++)
|
||||
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||||
});
|
||||
|
||||
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
|
||||
@@ -361,10 +413,11 @@ describe("TerminalView", () => {
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
||||
// Tracked again (a tmux that does report): xterm sends it, never twice.
|
||||
// Tracked again (a tmux that does report): sent once, by the tracked path.
|
||||
termState.modes.mouseTrackingMode = "any";
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
||||
expect(ws.sent).toHaveLength(2);
|
||||
screenBox();
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||
expect(ws.sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||
@@ -501,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", () => {
|
||||
@@ -583,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);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file BackupSettings.tsx
|
||||
* @description Settings → Data → Backup: the daily database backup of
|
||||
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
|
||||
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
|
||||
* @description Settings → Data → Backup: the scheduled database backup of
|
||||
* `server/lib/db-backup.js` — the folder (best on another disk), how often
|
||||
* (every day … every 30 days), the server's local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
|
||||
* while it runs, then toasts the outcome) and the newest backup in the folder.
|
||||
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
|
||||
* field the environment set is read-only. Restoring is manual and the card says
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { AlertTriangle, DatabaseBackup } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { BackupState } from "../../lib/api";
|
||||
@@ -23,12 +24,24 @@ import { SettingsSection } from "./SettingsRow";
|
||||
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** Days between backups offered in the picker (the server takes 1–30). */
|
||||
const EVERY = [1, 2, 3, 7, 14, 30];
|
||||
const everyLabel = (t: TFunction<"settings">, n: number) =>
|
||||
n === 1
|
||||
? t("backup.everyDay")
|
||||
: n === 7
|
||||
? t("backup.everyWeek")
|
||||
: n === 14
|
||||
? t("backup.everyTwoWeeks")
|
||||
: t("backup.everyDays", { count: n });
|
||||
|
||||
export function BackupSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [state, setState] = useState<BackupState | null>(null);
|
||||
const [dir, setDir] = useState("");
|
||||
const [hour, setHour] = useState(2);
|
||||
const [keep, setKeep] = useState("7");
|
||||
const [every, setEvery] = useState(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -37,6 +50,7 @@ export function BackupSettings() {
|
||||
setDir(s.dir);
|
||||
setHour(s.hour);
|
||||
setKeep(String(s.keep));
|
||||
setEvery(s.every ?? 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,11 +80,12 @@ export function BackupSettings() {
|
||||
|
||||
const keepN = Number(keep);
|
||||
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
|
||||
const patch: { dir?: string; hour?: number; keep?: number } = {};
|
||||
const patch: { dir?: string; hour?: number; keep?: number; every?: number } = {};
|
||||
if (state) {
|
||||
if (dir.trim() !== state.dir) patch.dir = dir.trim();
|
||||
if (hour !== state.hour) patch.hour = hour;
|
||||
if (keepN !== state.keep) patch.keep = keepN;
|
||||
if (every !== (state.every ?? 1)) patch.every = every;
|
||||
}
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
|
||||
@@ -125,12 +140,27 @@ export function BackupSettings() {
|
||||
</label>
|
||||
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={String(every)}
|
||||
onChange={(v) => setEvery(Number(v))}
|
||||
label={t("backup.every")}
|
||||
disabled={!state || owned?.every}
|
||||
searchable={false}
|
||||
options={(EVERY.includes(every)
|
||||
? EVERY
|
||||
: [...EVERY, every].sort((a, b) => a - b)
|
||||
).map((n) => ({ value: String(n), label: everyLabel(t, n) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<Select
|
||||
value={String(hour)}
|
||||
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>
|
||||
@@ -190,7 +220,7 @@ export function BackupSettings() {
|
||||
)}
|
||||
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
|
||||
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
|
||||
{(owned?.dir || owned?.hour || owned?.keep) && (
|
||||
{(owned?.dir || owned?.hour || owned?.keep || owned?.every) && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("privacy.envOwned")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -19,15 +19,33 @@
|
||||
* the next one below) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
* table above it, so this lists them only for a member. **Commit signing**
|
||||
* (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>
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
FileKey,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
PenLine,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
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";
|
||||
@@ -216,6 +234,339 @@ function GitHostForm({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
keys,
|
||||
keyLabel,
|
||||
busy,
|
||||
run,
|
||||
}: {
|
||||
id: GitIdentity;
|
||||
keys: Key[];
|
||||
keyLabel: (keyId: string | null | undefined) => string;
|
||||
busy: boolean;
|
||||
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 laneSigning = id.laneSigning ?? [];
|
||||
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
|
||||
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(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;
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.importKey({
|
||||
privateKey: form.text,
|
||||
...(form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
}),
|
||||
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
|
||||
);
|
||||
if (ok) setForm(null);
|
||||
};
|
||||
|
||||
const readFile = async (file: File | undefined) => {
|
||||
if (!file || !form) return;
|
||||
// A key file is a few KB; anything far larger is not one.
|
||||
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
|
||||
const text = await file.text();
|
||||
setForm({ ...form, text });
|
||||
};
|
||||
|
||||
const usable = keys.filter((k) => k.id);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-2 rounded-lg border border-border p-3"
|
||||
aria-label={t("gitIdentity.signTitle")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.signTitle")}
|
||||
</h3>
|
||||
{id.writable && !form && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setForm({ text: "", name: "" })}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
|
||||
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
{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 && (
|
||||
<form
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
aria-label={t("gitIdentity.importTitle")}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void doImport();
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
|
||||
<label className={`${BTN} cursor-pointer`}>
|
||||
<FileKey className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importFile")}
|
||||
<input
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={(e) => void readFile(e.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
aria-label={t("gitIdentity.importPaste")}
|
||||
placeholder={t("gitIdentity.importPaste")}
|
||||
value={form.text}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
rows={4}
|
||||
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||
className={`${INPUT} resize-y font-mono text-[10px]`}
|
||||
/>
|
||||
<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
|
||||
type="submit"
|
||||
disabled={busy || !form.text.trim()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitIdentity.importSubmit")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setForm(null)} className={BTN}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -572,6 +923,10 @@ export function GitIdentitySettings() {
|
||||
)}
|
||||
</form>
|
||||
|
||||
{id.signing !== undefined && (
|
||||
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
|
||||
)}
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
@@ -616,6 +971,11 @@ export function GitIdentitySettings() {
|
||||
onRemove={() => removeNamed(k.id!)}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
{id.signing?.state === "on" && id.signing.key === k.id && (
|
||||
<div className="text-[10px] text-status-success">
|
||||
{t("gitIdentity.signingBadge")}
|
||||
</div>
|
||||
)}
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file BackupSettings.test.tsx
|
||||
* @description Settings → Data → Backup: a save sends only the fields that
|
||||
* changed, "Back up now" is off until a folder is saved, and the newest backup
|
||||
* changed (a weekly schedule is `every: 7`), "Back up now" is off until a folder is saved, and the newest backup
|
||||
* in the folder is shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -22,6 +22,7 @@ const off = {
|
||||
dir: "",
|
||||
hour: 2,
|
||||
keep: 7,
|
||||
every: 1,
|
||||
running: false,
|
||||
last: null,
|
||||
files: [],
|
||||
@@ -50,6 +51,19 @@ describe("BackupSettings", () => {
|
||||
await waitFor(() => expect(run).toBeEnabled());
|
||||
});
|
||||
|
||||
it("saves a weekly schedule as every: 7", async () => {
|
||||
const user = userEvent.setup();
|
||||
backup.get.mockResolvedValue({ ...off, dir: "/mnt/hdd/b" });
|
||||
backup.save.mockImplementation((p) =>
|
||||
Promise.resolve({ ok: true, ...off, dir: "/mnt/hdd/b", ...p })
|
||||
);
|
||||
render(<BackupSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "How often" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Every week" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ every: 7 }));
|
||||
});
|
||||
|
||||
it("shows the newest backup", async () => {
|
||||
backup.get.mockResolvedValue({
|
||||
...off,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,11 @@
|
||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||
* 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.
|
||||
* the key's name with that key, and a second GitHub key is tied to its host;
|
||||
* 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";
|
||||
@@ -31,6 +35,18 @@ vi.mock("../../../lib/api", () => ({
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
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: {},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -38,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",
|
||||
@@ -335,4 +352,130 @@ describe("GitIdentitySettings", () => {
|
||||
});
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("imports a key without signing anything by itself", async () => {
|
||||
const signingKey = {
|
||||
...KEY,
|
||||
id: "id_ed25519_signing",
|
||||
type: "ed25519" as const,
|
||||
name: "signing",
|
||||
};
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
|
||||
...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(
|
||||
within(form).getByLabelText(/paste the private key/),
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----x"
|
||||
);
|
||||
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
|
||||
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
|
||||
});
|
||||
// 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("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: "off", key: null, path: null }, laneSigning: [] })
|
||||
);
|
||||
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 />);
|
||||
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(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}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file UserAvatar.tsx
|
||||
* @description An account's round picture: the signed-in account's own
|
||||
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
||||
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
||||
* /api/users/:id/avatar`, any signed-in account), versioned by `avatarAt`; without one —
|
||||
* or when the image fails to load — the first letters of its name. Used by the
|
||||
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
||||
* Users page and its editor, the pool board and member picker, and System
|
||||
@@ -23,7 +23,7 @@ export function UserAvatar({
|
||||
}: {
|
||||
name: string;
|
||||
avatarAt?: string | null;
|
||||
/** Another account's (staff only); omitted = the signed-in account's own. */
|
||||
/** Another account's; omitted = the signed-in account's own. */
|
||||
userId?: number;
|
||||
/** How many letters of the name stand in for a missing picture. */
|
||||
letters?: number;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user