Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e21120d51d | |||
| c946b73d11 | |||
| 3daf7ed4cb | |||
| f9869ada6d | |||
| 0fc436dc0c | |||
| cd25791160 | |||
| 2a83551eee | |||
| d0889d5250 | |||
| 6958a7b4f6 | |||
| aa7f1b1c60 | |||
| 70d7cf30c2 | |||
| 5912e9e613 | |||
| 497faae3bf | |||
| e6ce8009b1 | |||
| 705353b0da | |||
| 0792c88612 | |||
| a3d1dce2ca |
@@ -39,6 +39,16 @@
|
|||||||
"async": true
|
"async": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Artifact|mcp__claude_ai_.*__batch",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/artifact-export-hint.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Stop": [
|
"Stop": [
|
||||||
|
|||||||
+116
@@ -1133,6 +1133,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) {
|
async function cmdPricing(flags, positional) {
|
||||||
const sub = positional[0];
|
const sub = positional[0];
|
||||||
if (sub === "set" && positional[1]) {
|
if (sub === "set" && positional[1]) {
|
||||||
@@ -2934,6 +3035,19 @@ const COMMAND_GROUPS = [
|
|||||||
["remote-sources rm <id>", "[--purge]", "Remove a source (--purge also deletes its data)"],
|
["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",
|
"Workspaces",
|
||||||
[
|
[
|
||||||
@@ -4549,6 +4663,8 @@ async function runCommand(argv) {
|
|||||||
case "remote-sources":
|
case "remote-sources":
|
||||||
case "remotes":
|
case "remotes":
|
||||||
return cmdRemoteSources(flags, positional);
|
return cmdRemoteSources(flags, positional);
|
||||||
|
case "remote":
|
||||||
|
return cmdRemote(flags, positional);
|
||||||
case "pricing":
|
case "pricing":
|
||||||
return cmdPricing(flags, positional);
|
return cmdPricing(flags, positional);
|
||||||
case "import":
|
case "import":
|
||||||
|
|||||||
Generated
+247
@@ -25,6 +25,7 @@
|
|||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.61.0",
|
"lucide-react": "^1.61.0",
|
||||||
|
"pdfjs-dist": "6.4.299",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
@@ -1194,6 +1195,241 @@
|
|||||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"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": ">= 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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.61.0",
|
"lucide-react": "^1.61.0",
|
||||||
|
"pdfjs-dist": "6.4.299",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
|||||||
"cost_threshold",
|
"cost_threshold",
|
||||||
"budget",
|
"budget",
|
||||||
];
|
];
|
||||||
|
/** Only a hub (server mode) has remote clients to alert about. */
|
||||||
|
const SERVER_RULE_TYPES: AlertRuleType[] = ["remote_unmonitored"];
|
||||||
|
|
||||||
type TabKey = "rules" | "channels" | "activity";
|
type TabKey = "rules" | "channels" | "activity";
|
||||||
|
|
||||||
@@ -243,6 +245,8 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
|
|||||||
if (form.budget_tool) config.tool = form.budget_tool;
|
if (form.budget_tool) config.tool = form.budget_tool;
|
||||||
return config;
|
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"}`),
|
period: t(`periods.${c.period ?? "month"}`),
|
||||||
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
||||||
});
|
});
|
||||||
|
case "remote_unmonitored":
|
||||||
|
return t("ruleDesc.remoteUnmonitored");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +292,8 @@ export function AlertsNotifications() {
|
|||||||
const { t: ts } = useTranslation("settings");
|
const { t: ts } = useTranslation("settings");
|
||||||
|
|
||||||
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
// 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");
|
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||||
|
|
||||||
// Rules
|
// Rules
|
||||||
@@ -613,7 +620,7 @@ export function AlertsNotifications() {
|
|||||||
title={editingId ? t("rules.typeFixed") : undefined}
|
title={editingId ? t("rules.typeFixed") : undefined}
|
||||||
className="select w-full"
|
className="select w-full"
|
||||||
>
|
>
|
||||||
{RULE_TYPES.map((type) => (
|
{ruleTypes.map((type) => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
{t(`ruleTypes.${type}`)}
|
{t(`ruleTypes.${type}`)}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -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,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file ChatDock.tsx
|
|
||||||
* @description Chat between users for someone who hid the pet (server mode):
|
|
||||||
* a round 💬 button in the bottom-right corner, above the status bar, with
|
|
||||||
* the unread count, that opens the same chat window the pet has (`ChatHub.tsx`
|
|
||||||
* — rooms rail without the pet, a room, New chat). A new message from someone
|
|
||||||
* shows as the same card above the button while the window is closed. Mounted
|
|
||||||
* by `SagiMount` only while the pet is off, so the two never both run.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { MessagesSquare } from "lucide-react";
|
|
||||||
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
|
||||||
import { useChat } from "./useChat";
|
|
||||||
|
|
||||||
export function ChatDock() {
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const chat = useChat();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [pane, setPane] = useState<"new" | number>("new");
|
|
||||||
const [settingsAsk, setSettingsAsk] = useState(0);
|
|
||||||
const room = typeof pane === "number" ? chat.rooms.find((r) => r.id === pane) : undefined;
|
|
||||||
|
|
||||||
// Opening with nothing picked: the newest chat, else New chat.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
if (typeof pane === "number" && !room) setPane(chat.rooms[0]?.id ?? "new");
|
|
||||||
}, [open, pane, room, chat.rooms]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && pane === "new" && chat.rooms[0]) setPane(chat.rooms[0].id);
|
|
||||||
// Only when the window opens.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const { setActive } = chat;
|
|
||||||
useEffect(() => {
|
|
||||||
setActive(open && typeof pane === "number" ? pane : null);
|
|
||||||
}, [open, pane, setActive]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
if (!chat.enabled) return null;
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-10 right-4 z-40 flex flex-col items-end gap-2">
|
|
||||||
{open && (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<ChatRail
|
|
||||||
chat={chat}
|
|
||||||
active={pane}
|
|
||||||
onSelect={(v) => setPane(v === "pet" ? "new" : v)}
|
|
||||||
onSettings={(id) => {
|
|
||||||
setPane(id);
|
|
||||||
setSettingsAsk((n) => n + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{room ? (
|
|
||||||
<ChatRoomView
|
|
||||||
key={`${room.id}:${room.clearedId ?? 0}`}
|
|
||||||
chat={chat}
|
|
||||||
room={room}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
onLeft={() => setPane("new")}
|
|
||||||
onOpenRoom={setPane}
|
|
||||||
showSettings={settingsAsk}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<NewChat chat={chat} onOpen={setPane} onClose={() => setOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!open && chat.notice && (
|
|
||||||
<ChatNotice
|
|
||||||
notice={chat.notice}
|
|
||||||
onDismiss={chat.dismissNotice}
|
|
||||||
onOpen={(roomId) => {
|
|
||||||
chat.dismissNotice();
|
|
||||||
setPane(roomId);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="relative flex h-11 w-11 items-center justify-center rounded-full bg-accent-solid text-white shadow-lg transition-opacity hover:opacity-90"
|
|
||||||
aria-label={
|
|
||||||
chat.unread ? t("sagi.people.dockUnread", { n: chat.unread }) : t("sagi.people.dock")
|
|
||||||
}
|
|
||||||
title={t("sagi.people.dock")}
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<MessagesSquare size={20} />
|
|
||||||
{chat.unread > 0 && (
|
|
||||||
<span className="absolute -right-1 -top-1 min-w-[1.25rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-5 text-white">
|
|
||||||
{chat.unread > 99 ? "99+" : chat.unread}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,12 +5,8 @@
|
|||||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
* 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
|
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||||
* shares the single WebSocket.
|
* shares the single WebSocket.
|
||||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
* Clicking the pet opens its window (`PetPanel`): its name, a pat, and the
|
||||||
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
* status strip (`SagiInfo`). The pet no longer chats (v2.8.68).
|
||||||
* itself is answered there, anything else goes to the AI. In server mode a
|
|
||||||
* rail beside it (`ChatHub.tsx`, `useChat.ts`) switches to direct chats and
|
|
||||||
* groups with other users, and a blue dot on the pet counts their unread
|
|
||||||
* messages.
|
|
||||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||||
* detection, and route navigation. Reactive personality + status/Ask come
|
* detection, and route navigation. Reactive personality + status/Ask come
|
||||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||||
@@ -18,10 +14,7 @@
|
|||||||
* bubble/panel render in a self-clamping flyout so
|
* bubble/panel render in a self-clamping flyout so
|
||||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
* 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
|
* ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||||
* 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
|
|
||||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -94,17 +87,12 @@ import { PetNameButton, SagiInfo } from "./SagiInfo";
|
|||||||
import { useSagiBrain } from "./useSagiBrain";
|
import { useSagiBrain } from "./useSagiBrain";
|
||||||
import { useSagiPosition } from "./useSagiPosition";
|
import { useSagiPosition } from "./useSagiPosition";
|
||||||
import { useSagiEnabled } from "./SagiMount";
|
import { useSagiEnabled } from "./SagiMount";
|
||||||
import { matchIntent } from "./intents";
|
|
||||||
import { petName, sagiPrefs } from "./prefs";
|
import { petName, sagiPrefs } from "./prefs";
|
||||||
import type { SagiSubject } from "./brain";
|
import type { SagiSubject } from "./brain";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
|
||||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
|
||||||
import { workspaceHref } from "../SessionRowMenu";
|
import { workspaceHref } from "../SessionRowMenu";
|
||||||
import { Heart } from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
import { PetPanel } from "./PetPanel";
|
||||||
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
|
||||||
import { useChat } from "./useChat";
|
|
||||||
|
|
||||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
// 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.
|
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||||
@@ -234,7 +222,7 @@ function SagiBody() {
|
|||||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
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);
|
const [open, setOpen] = useState(false);
|
||||||
// The right-click pet menu (`PetMenu.tsx`).
|
// The right-click pet menu (`PetMenu.tsx`).
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
@@ -243,21 +231,6 @@ function SagiBody() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const href = useSettingsHref();
|
const href = useSettingsHref();
|
||||||
const brain = useSagiBrain();
|
const brain = useSagiBrain();
|
||||||
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
|
|
||||||
const people = useChat();
|
|
||||||
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
|
|
||||||
// Bumped by the rail's "Group settings": the room opens on its settings.
|
|
||||||
const [settingsAsk, setSettingsAsk] = useState(0);
|
|
||||||
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
|
|
||||||
// The room on screen: no notice for its messages.
|
|
||||||
const { setActive } = people;
|
|
||||||
useEffect(() => {
|
|
||||||
setActive(open && typeof pane === "number" ? pane : null);
|
|
||||||
}, [open, pane, setActive]);
|
|
||||||
// A room that went away (left, removed): back to the pet.
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
|
|
||||||
}, [pane, room, people.rooms.length]);
|
|
||||||
const place = useSagiPosition(size);
|
const place = useSagiPosition(size);
|
||||||
|
|
||||||
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
||||||
@@ -360,43 +333,6 @@ function SagiBody() {
|
|||||||
[brain, navigate]
|
[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.
|
// 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 stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||||
const onStroke = (e: React.PointerEvent) => {
|
const onStroke = (e: React.PointerEvent) => {
|
||||||
@@ -418,8 +354,6 @@ function SagiBody() {
|
|||||||
s.dir = dir;
|
s.dir = dir;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
|
||||||
|
|
||||||
const anchor: Anchor = {
|
const anchor: Anchor = {
|
||||||
left: place.left,
|
left: place.left,
|
||||||
top: place.top,
|
top: place.top,
|
||||||
@@ -432,38 +366,10 @@ function SagiBody() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||||
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
it 1:1). */}
|
||||||
re-attaches the same session. */}
|
|
||||||
{open && (
|
{open && (
|
||||||
<SagiFlyout anchor={anchor} beside>
|
<SagiFlyout anchor={anchor} beside>
|
||||||
<div className="flex items-start gap-2">
|
<PetPanel
|
||||||
{people.enabled && (
|
|
||||||
<ChatRail
|
|
||||||
chat={people}
|
|
||||||
active={pane}
|
|
||||||
onSelect={setPane}
|
|
||||||
petName={name}
|
|
||||||
onSettings={(id) => {
|
|
||||||
setPane(id);
|
|
||||||
setSettingsAsk((n) => n + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{people.enabled && pane === "new" ? (
|
|
||||||
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
|
|
||||||
) : people.enabled && room ? (
|
|
||||||
<ChatRoomView
|
|
||||||
// Cleared for me: read the (now shorter) history again.
|
|
||||||
key={`${room.id}:${room.clearedId ?? 0}`}
|
|
||||||
chat={people}
|
|
||||||
room={room}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
onLeft={() => setPane("pet")}
|
|
||||||
onOpenRoom={setPane}
|
|
||||||
showSettings={settingsAsk}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PetChat
|
|
||||||
name={name}
|
name={name}
|
||||||
title={
|
title={
|
||||||
<PetNameButton
|
<PetNameButton
|
||||||
@@ -496,16 +402,8 @@ function SagiBody() {
|
|||||||
defaultOpen={brain.attention.length > 0}
|
defaultOpen={brain.attention.length > 0}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onLocal={onLocal}
|
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
onOpenWorkspace={(laneId, runId) => {
|
|
||||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
onBusy={onBusy}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -523,21 +421,7 @@ function SagiBody() {
|
|||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!open && !menu && people.notice && (
|
{!open && !menu && brain.bubble && (
|
||||||
<SagiFlyout anchor={anchor}>
|
|
||||||
<ChatNotice
|
|
||||||
notice={people.notice}
|
|
||||||
onDismiss={people.dismissNotice}
|
|
||||||
onOpen={(roomId) => {
|
|
||||||
people.dismissNotice();
|
|
||||||
setPane(roomId);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SagiFlyout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!open && !menu && !people.notice && brain.bubble && (
|
|
||||||
<SagiFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<SpeechBubble
|
<SpeechBubble
|
||||||
bubble={brain.bubble}
|
bubble={brain.bubble}
|
||||||
@@ -613,11 +497,6 @@ function SagiBody() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{people.unread > 0 && (
|
|
||||||
<span className="sagi-chat-dot" aria-hidden>
|
|
||||||
{people.unread > 9 ? "9+" : people.unread}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{brain.status.errorCount > 0 ? (
|
{brain.status.errorCount > 0 ? (
|
||||||
<span className="sagi-error-dot" aria-hidden>
|
<span className="sagi-error-dot" aria-hidden>
|
||||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file SagiInfo.tsx
|
* @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
|
* the name you rename by clicking it (`PetNameButton`), and a status strip
|
||||||
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
||||||
* always in sight, and on expanding, the sessions that need the user, by
|
* 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"
|
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label={t("sagi.chat.status")}
|
aria-label={t("sagi.panel.status")}
|
||||||
title={t("sagi.chat.status")}
|
title={t("sagi.panel.status")}
|
||||||
>
|
>
|
||||||
<StatChip
|
<StatChip
|
||||||
icon={Radio}
|
icon={Radio}
|
||||||
|
|||||||
@@ -3,17 +3,14 @@
|
|||||||
* @description Gate for the Sagi mascot: follows the enabled preference
|
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||||
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||||
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||||
* WebSocket reducer. While it is off, the chat between users still has a
|
* WebSocket reducer.
|
||||||
* way in: `ChatDock` (its own chunk, server mode only).
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { sagiPrefs } from "./prefs";
|
import { sagiPrefs } from "./prefs";
|
||||||
import { useAuth } from "../AuthGate";
|
|
||||||
|
|
||||||
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||||
const ChatDock = lazy(() => import("./ChatDock").then((m) => ({ default: m.ChatDock })));
|
|
||||||
|
|
||||||
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||||
export function useSagiEnabled(): boolean {
|
export function useSagiEnabled(): boolean {
|
||||||
@@ -23,16 +20,7 @@ export function useSagiEnabled(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SagiMount() {
|
export function SagiMount() {
|
||||||
const { state } = useAuth();
|
if (!useSagiEnabled()) return null;
|
||||||
if (!useSagiEnabled()) {
|
|
||||||
// The pet hidden: chat with other users from a button of its own.
|
|
||||||
if (state.mode !== "server") return null;
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ChatDock />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Sagi />
|
<Sagi />
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file ChatDock.test.tsx
|
|
||||||
* @description The chat button shown while the pet is hidden (`ChatDock.tsx`,
|
|
||||||
* mounted by `SagiMount`): server mode only, the unread count on it, and a
|
|
||||||
* click opens the newest chat with the rooms rail beside it (no pet item).
|
|
||||||
* @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";
|
|
||||||
|
|
||||||
const auth = vi.hoisted(() => ({
|
|
||||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
|
|
||||||
}));
|
|
||||||
const api = vi.hoisted(() => ({
|
|
||||||
chat: {
|
|
||||||
rooms: vi.fn(),
|
|
||||||
people: vi.fn(),
|
|
||||||
messages: vi.fn(),
|
|
||||||
read: vi.fn(),
|
|
||||||
fileUrl: () => "",
|
|
||||||
},
|
|
||||||
users: { avatarUrl: () => null },
|
|
||||||
me: { avatarUrl: () => null },
|
|
||||||
}));
|
|
||||||
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
|
|
||||||
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
|
|
||||||
|
|
||||||
import { ChatDock } from "../ChatDock";
|
|
||||||
|
|
||||||
const room = {
|
|
||||||
id: 7,
|
|
||||||
kind: "dm" as const,
|
|
||||||
name: "",
|
|
||||||
createdBy: 1,
|
|
||||||
createdAt: "2026-11-03T01:00:00Z",
|
|
||||||
lastMessageAt: null,
|
|
||||||
members: [1, 2],
|
|
||||||
lastMessage: null,
|
|
||||||
lastReadId: 0,
|
|
||||||
unread: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
|
|
||||||
api.chat.rooms.mockResolvedValue({ rooms: [room] });
|
|
||||||
api.chat.people.mockResolvedValue({
|
|
||||||
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
|
|
||||||
});
|
|
||||||
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
|
|
||||||
api.chat.read.mockResolvedValue({ lastReadId: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ChatDock", () => {
|
|
||||||
it("shows the unread count and opens the newest chat", async () => {
|
|
||||||
render(<ChatDock />);
|
|
||||||
const btn = await screen.findByRole("button", { name: "Chats, 3 unread" });
|
|
||||||
fireEvent.click(btn);
|
|
||||||
await waitFor(() => expect(screen.getByRole("dialog", { name: "Chat with An" })).toBeTruthy());
|
|
||||||
expect(screen.queryByText("🐾")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is not there in local mode", () => {
|
|
||||||
auth.state = { mode: "local" };
|
|
||||||
render(<ChatDock />);
|
|
||||||
expect(screen.queryByRole("button", { name: /Chats/ })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file ChatHub.test.tsx
|
|
||||||
* @description The pet window's chat between users (`ChatHub.tsx`): the rail
|
|
||||||
* lists the pet then the rooms with their unread counts; New chat opens a
|
|
||||||
* direct chat for one person and makes a group for several; a room reads its
|
|
||||||
* messages (a group names who wrote each), marks them read, appends one that
|
|
||||||
* arrives live, and sends text with the attached files; the group settings
|
|
||||||
* scroll only one way; a new message raises a notice that opens its room; and
|
|
||||||
* the right-click menus of a room, a message, a file and a group member —
|
|
||||||
* deleting a chat for yourself and disbanding a group (its creator) included;
|
|
||||||
* "Seen" under your newest message; a disabled account never offered for a
|
|
||||||
* new chat, a deleted one named as such; and the rail without the pet.
|
|
||||||
* @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 type { ChatMessage, ChatRoom } from "../../../lib/api";
|
|
||||||
import type { ChatState } from "../useChat";
|
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
|
||||||
chat: {
|
|
||||||
openDm: vi.fn(),
|
|
||||||
createGroup: vi.fn(),
|
|
||||||
messages: vi.fn(),
|
|
||||||
post: vi.fn(),
|
|
||||||
read: vi.fn(),
|
|
||||||
updateGroup: vi.fn(),
|
|
||||||
leave: vi.fn(),
|
|
||||||
deleteMessage: vi.fn(),
|
|
||||||
clear: vi.fn(),
|
|
||||||
disband: vi.fn(),
|
|
||||||
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
|
|
||||||
},
|
|
||||||
users: { avatarUrl: () => null },
|
|
||||||
me: { avatarUrl: () => null },
|
|
||||||
}));
|
|
||||||
vi.mock("../../../lib/api", async (orig) => ({
|
|
||||||
...(await orig<Record<string, unknown>>()),
|
|
||||||
api,
|
|
||||||
}));
|
|
||||||
const clip = vi.hoisted(() => ({ copyText: vi.fn(async () => true) }));
|
|
||||||
vi.mock("../../../lib/clipboard", () => clip);
|
|
||||||
const confirm = vi.hoisted(() => ({ confirmDialog: vi.fn(async () => true) }));
|
|
||||||
vi.mock("../../../lib/confirm", () => confirm);
|
|
||||||
vi.mock("../../AuthGate", () => ({
|
|
||||||
useAuth: () => ({
|
|
||||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "../ChatHub";
|
|
||||||
|
|
||||||
const msg = (id: number, userId: number, body: string): ChatMessage => ({
|
|
||||||
id,
|
|
||||||
roomId: 7,
|
|
||||||
userId,
|
|
||||||
kind: "text",
|
|
||||||
body,
|
|
||||||
createdAt: "2026-11-03T02:00:00Z",
|
|
||||||
files: [],
|
|
||||||
});
|
|
||||||
const group: ChatRoom = {
|
|
||||||
id: 7,
|
|
||||||
kind: "group",
|
|
||||||
name: "Team",
|
|
||||||
createdBy: 1,
|
|
||||||
createdAt: "2026-11-03T01:00:00Z",
|
|
||||||
lastMessageAt: null,
|
|
||||||
members: [1, 2, 3],
|
|
||||||
lastMessage: null,
|
|
||||||
lastReadId: 0,
|
|
||||||
unread: 2,
|
|
||||||
};
|
|
||||||
const dm: ChatRoom = { ...group, id: 8, kind: "dm", name: "", members: [1, 2], unread: 0 };
|
|
||||||
|
|
||||||
function state(over: Partial<ChatState> = {}): ChatState {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
me: 1,
|
|
||||||
rooms: [group, dm],
|
|
||||||
people: [
|
|
||||||
{ id: 2, username: "an", displayName: "An", avatarAt: null },
|
|
||||||
{ id: 3, username: "binh", displayName: null, avatarAt: null },
|
|
||||||
],
|
|
||||||
unread: 2,
|
|
||||||
reload: vi.fn(async () => {}),
|
|
||||||
upsert: vi.fn(),
|
|
||||||
onMessage: vi.fn(),
|
|
||||||
markRead: vi.fn(),
|
|
||||||
setActive: vi.fn(),
|
|
||||||
notice: null,
|
|
||||||
dismissNotice: vi.fn(),
|
|
||||||
onDeleted: vi.fn(),
|
|
||||||
muted: new Set<number>(),
|
|
||||||
toggleMute: vi.fn(),
|
|
||||||
reloadPeople: vi.fn(async () => {}),
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
describe("ChatHub", () => {
|
|
||||||
it("lists the pet, then each room with its unread count", () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
render(<ChatRail chat={state()} active="pet" onSelect={onSelect} petName="Sagi" />);
|
|
||||||
expect(screen.getByRole("button", { name: "Sagi" })).toHaveAttribute("aria-pressed", "true");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Team, 2 unread" }));
|
|
||||||
expect(onSelect).toHaveBeenCalledWith(7);
|
|
||||||
// A direct chat is named after the other person.
|
|
||||||
expect(screen.getByRole("button", { name: "An" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens a direct chat for one person and a group for several", async () => {
|
|
||||||
const chat = state();
|
|
||||||
const onOpen = vi.fn();
|
|
||||||
api.chat.openDm.mockResolvedValue({ room: dm });
|
|
||||||
render(<NewChat chat={chat} onOpen={onOpen} onClose={() => {}} />);
|
|
||||||
fireEvent.click(screen.getByRole("checkbox", { name: /An/ }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Start chat" }));
|
|
||||||
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(8));
|
|
||||||
expect(api.chat.openDm).toHaveBeenCalledWith(2);
|
|
||||||
|
|
||||||
api.chat.createGroup.mockResolvedValue({ room: group });
|
|
||||||
fireEvent.click(screen.getByRole("checkbox", { name: /binh/ }));
|
|
||||||
fireEvent.change(screen.getByLabelText("Group name (optional)"), { target: { value: "Team" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Create group (2 people)" }));
|
|
||||||
await waitFor(() => expect(api.chat.createGroup).toHaveBeenCalledWith("Team", [2, 3]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads the room, names group senders, marks it read and sends with files", async () => {
|
|
||||||
const chat = state();
|
|
||||||
api.chat.messages.mockResolvedValue({
|
|
||||||
messages: [msg(1, 2, "chào"), msg(2, 1, "hi")],
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
api.chat.read.mockResolvedValue({ lastReadId: 2 });
|
|
||||||
render(
|
|
||||||
<ChatRoomView
|
|
||||||
chat={chat}
|
|
||||||
room={group}
|
|
||||||
onClose={() => {}}
|
|
||||||
onLeft={() => {}}
|
|
||||||
onOpenRoom={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(await screen.findByText("chào")).toBeTruthy();
|
|
||||||
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
|
|
||||||
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
|
|
||||||
expect(chat.markRead).toHaveBeenCalledWith(7, 2);
|
|
||||||
|
|
||||||
// A message arriving live is appended.
|
|
||||||
const calls = vi.mocked(chat.onMessage).mock.calls;
|
|
||||||
const push = calls[calls.length - 1]![0]!;
|
|
||||||
push(msg(3, 3, "mới"));
|
|
||||||
expect(await screen.findByText("mới")).toBeTruthy();
|
|
||||||
|
|
||||||
const file = new File(["x"], "a.png", { type: "image/png" });
|
|
||||||
const box = screen.getByLabelText("Message Team");
|
|
||||||
fireEvent.paste(box, { clipboardData: { files: [file] } });
|
|
||||||
expect(screen.getByText("a.png")).toBeTruthy();
|
|
||||||
fireEvent.change(box, { target: { value: "xem nè" } });
|
|
||||||
api.chat.post.mockResolvedValue({ message: { ...msg(4, 1, "xem nè") } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
||||||
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
|
|
||||||
expect(await screen.findByText("xem nè")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the group settings to one vertical scroll, the name field shrinking", async () => {
|
|
||||||
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
|
|
||||||
render(
|
|
||||||
<ChatRoomView
|
|
||||||
chat={state()}
|
|
||||||
room={group}
|
|
||||||
onClose={() => {}}
|
|
||||||
onLeft={() => {}}
|
|
||||||
onOpenRoom={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
|
|
||||||
const name = screen.getAllByLabelText("Group name (optional)")[0]!;
|
|
||||||
expect(name.className).toContain("min-w-0");
|
|
||||||
const panel = name.closest(".overflow-y-auto")!;
|
|
||||||
expect(panel.className).toContain("overflow-x-hidden");
|
|
||||||
// The add-people list grows inside that panel instead of scrolling itself.
|
|
||||||
const lists = panel.querySelectorAll("ul.overflow-y-auto");
|
|
||||||
expect(lists.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a new message as a card that opens its room", () => {
|
|
||||||
const onOpen = vi.fn();
|
|
||||||
const onDismiss = vi.fn();
|
|
||||||
render(
|
|
||||||
<ChatNotice
|
|
||||||
notice={{ key: 1, roomId: 7, title: "An · Team", text: "họp lúc 2h nhé" }}
|
|
||||||
onOpen={onOpen}
|
|
||||||
onDismiss={onDismiss}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.getByText("họp lúc 2h nhé")).toBeTruthy();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Open the chat with An · Team" }));
|
|
||||||
expect(onOpen).toHaveBeenCalledWith(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("right-click on a room: mark read, mute, copy its name, settings and leave", async () => {
|
|
||||||
const chat = state({ rooms: [{ ...group, lastMessage: msg(5, 2, "x") }, dm] });
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
const onSettings = vi.fn();
|
|
||||||
api.chat.read.mockResolvedValue({ lastReadId: 5 });
|
|
||||||
api.chat.leave.mockResolvedValue({ ok: true });
|
|
||||||
render(
|
|
||||||
<ChatRail chat={chat} active={7} onSelect={onSelect} petName="Sagi" onSettings={onSettings} />
|
|
||||||
);
|
|
||||||
const open = () =>
|
|
||||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
|
|
||||||
open();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Mark as read" }));
|
|
||||||
expect(chat.markRead).toHaveBeenCalledWith(7, 5);
|
|
||||||
expect(api.chat.read).toHaveBeenCalledWith(7, 5);
|
|
||||||
open();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Mute notifications" }));
|
|
||||||
expect(chat.toggleMute).toHaveBeenCalledWith(7);
|
|
||||||
open();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Copy name" }));
|
|
||||||
expect(clip.copyText).toHaveBeenCalledWith("Team");
|
|
||||||
open();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Group settings" }));
|
|
||||||
expect(onSettings).toHaveBeenCalledWith(7);
|
|
||||||
open();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Leave group" }));
|
|
||||||
await waitFor(() => expect(api.chat.leave).toHaveBeenCalledWith(7));
|
|
||||||
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
|
|
||||||
// A direct chat has no group items.
|
|
||||||
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
|
|
||||||
expect(screen.queryByRole("menuitem", { name: "Leave group" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("right-click on a message: copy, reply, message the sender, delete your own", async () => {
|
|
||||||
const chat = state();
|
|
||||||
const onOpenRoom = vi.fn();
|
|
||||||
api.chat.messages.mockResolvedValue({
|
|
||||||
messages: [
|
|
||||||
msg(1, 2, "của An"),
|
|
||||||
{
|
|
||||||
...msg(2, 1, "của tôi"),
|
|
||||||
files: [{ id: "f1", name: "a.pdf", type: "application/pdf", size: 9 }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
api.chat.read.mockResolvedValue({ lastReadId: 2 });
|
|
||||||
api.chat.openDm.mockResolvedValue({ room: dm });
|
|
||||||
api.chat.deleteMessage.mockResolvedValue({ ok: true });
|
|
||||||
render(
|
|
||||||
<ChatRoomView
|
|
||||||
chat={chat}
|
|
||||||
room={group}
|
|
||||||
onClose={() => {}}
|
|
||||||
onLeft={() => {}}
|
|
||||||
onOpenRoom={onOpenRoom}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
fireEvent.contextMenu(await screen.findByText("của An"));
|
|
||||||
expect(screen.queryByRole("menuitem", { name: "Delete message" })).toBeNull();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Copy text" }));
|
|
||||||
expect(clip.copyText).toHaveBeenCalledWith("của An");
|
|
||||||
|
|
||||||
fireEvent.contextMenu(screen.getByText("của An"));
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Reply" }));
|
|
||||||
expect((screen.getByLabelText("Message Team") as HTMLTextAreaElement).value).toBe(
|
|
||||||
"An:\n> của An\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.contextMenu(screen.getByText("của An"));
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
|
|
||||||
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
|
|
||||||
|
|
||||||
// A file has its own items.
|
|
||||||
fireEvent.contextMenu(screen.getByText("a.pdf"));
|
|
||||||
expect(screen.getByRole("menuitem", { name: "Download" })).toBeTruthy();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Copy file name" }));
|
|
||||||
expect(clip.copyText).toHaveBeenCalledWith("a.pdf");
|
|
||||||
|
|
||||||
fireEvent.contextMenu(screen.getByText("của tôi"));
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete message" }));
|
|
||||||
await waitFor(() => expect(api.chat.deleteMessage).toHaveBeenCalledWith(7, 2));
|
|
||||||
await waitFor(() => expect(screen.queryByText("của tôi")).toBeNull());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("right-click on a group member: message them; the creator may remove them", async () => {
|
|
||||||
const onOpenRoom = vi.fn();
|
|
||||||
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
|
|
||||||
api.chat.openDm.mockResolvedValue({ room: dm });
|
|
||||||
api.chat.updateGroup.mockResolvedValue({ room: { ...group, members: [1, 3] } });
|
|
||||||
render(
|
|
||||||
<ChatRoomView
|
|
||||||
chat={state()}
|
|
||||||
room={group}
|
|
||||||
onClose={() => {}}
|
|
||||||
onLeft={() => {}}
|
|
||||||
onOpenRoom={onOpenRoom}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
|
|
||||||
const row = screen
|
|
||||||
.getAllByText("An")
|
|
||||||
.find((el) => el.closest("li"))!
|
|
||||||
.closest("li")!;
|
|
||||||
fireEvent.contextMenu(row);
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Remove An" }));
|
|
||||||
await waitFor(() => expect(api.chat.updateGroup).toHaveBeenCalledWith(7, { remove: [2] }));
|
|
||||||
fireEvent.contextMenu(row);
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
|
|
||||||
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes a chat for yourself, and lets the creator disband a group", async () => {
|
|
||||||
const chat = state();
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
api.chat.clear.mockResolvedValue({ hidden: true });
|
|
||||||
api.chat.disband.mockResolvedValue({ ok: true });
|
|
||||||
render(<ChatRail chat={chat} active={8} onSelect={onSelect} petName="Sagi" />);
|
|
||||||
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
|
|
||||||
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete chat" }));
|
|
||||||
await waitFor(() => expect(api.chat.clear).toHaveBeenCalledWith(8));
|
|
||||||
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
|
|
||||||
expect(confirm.confirmDialog).toHaveBeenCalled();
|
|
||||||
|
|
||||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
|
|
||||||
expect(screen.getByRole("menuitem", { name: "Delete chat history" })).toBeTruthy();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Disband group" }));
|
|
||||||
await waitFor(() => expect(api.chat.disband).toHaveBeenCalledWith(7));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers Disband only to the group's creator", () => {
|
|
||||||
render(<ChatRail chat={state({ me: 2 })} active="pet" onSelect={() => {}} petName="Sagi" />);
|
|
||||||
fireEvent.contextMenu(screen.getByRole("button", { name: /Team/ }));
|
|
||||||
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
|
|
||||||
expect(screen.getByRole("menuitem", { name: "Leave group" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows who has seen your newest message", async () => {
|
|
||||||
api.chat.messages.mockResolvedValue({
|
|
||||||
messages: [msg(1, 2, "của An"), msg(2, 1, "của tôi")],
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
api.chat.read.mockResolvedValue({ lastReadId: 2 });
|
|
||||||
const view = (room: ChatRoom) => (
|
|
||||||
<ChatRoomView
|
|
||||||
chat={state()}
|
|
||||||
room={room}
|
|
||||||
onClose={() => {}}
|
|
||||||
onLeft={() => {}}
|
|
||||||
onOpenRoom={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
// Direct chat, not read yet: nothing; read: "Seen".
|
|
||||||
const { rerender } = render(view({ ...dm, id: 7, reads: { 1: 2, 2: 1 } }));
|
|
||||||
await screen.findByText("của tôi");
|
|
||||||
expect(screen.queryByTestId("chat-seen")).toBeNull();
|
|
||||||
rerender(view({ ...dm, id: 7, reads: { 1: 2, 2: 2 } }));
|
|
||||||
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen");
|
|
||||||
// Group: by name, then everyone.
|
|
||||||
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 1 } }));
|
|
||||||
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by An");
|
|
||||||
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 2 } }));
|
|
||||||
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by everyone");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never offers a disabled account, and names a deleted one", () => {
|
|
||||||
const chat = state({
|
|
||||||
people: [
|
|
||||||
{ id: 2, username: "an", displayName: "An", avatarAt: null },
|
|
||||||
{ id: 3, username: "binh", displayName: null, avatarAt: null, disabled: true },
|
|
||||||
],
|
|
||||||
// A direct chat whose other person's account was deleted.
|
|
||||||
rooms: [{ ...dm, members: [1] }],
|
|
||||||
});
|
|
||||||
render(<NewChat chat={chat} onOpen={() => {}} onClose={() => {}} />);
|
|
||||||
expect(screen.queryByRole("checkbox", { name: /binh/ })).toBeNull();
|
|
||||||
expect(chat.reloadPeople).toHaveBeenCalled();
|
|
||||||
render(<ChatRail chat={chat} active="new" onSelect={() => {}} />);
|
|
||||||
expect(screen.getByRole("button", { name: "Deleted user" })).toBeTruthy();
|
|
||||||
// No pet name: no pet item.
|
|
||||||
expect(screen.queryByText("🐾")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -261,9 +261,7 @@ describe("PetMenu", () => {
|
|||||||
renderMenu();
|
renderMenu();
|
||||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||||
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
|
|
||||||
);
|
|
||||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,9 +313,7 @@ describe("PetSettings", () => {
|
|||||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||||
render(<PetSettings />);
|
render(<PetSettings />);
|
||||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||||
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
|
|
||||||
);
|
|
||||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sagi.test.tsx
|
* @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>
|
* @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 { MemoryRouter } from "react-router-dom";
|
||||||
import { Sagi } from "../Sagi";
|
import { Sagi } from "../Sagi";
|
||||||
import { eventBus } from "../../../lib/eventBus";
|
import { eventBus } from "../../../lib/eventBus";
|
||||||
import { api } from "../../../lib/api";
|
|
||||||
import type { WSMessage, Session } from "../../../lib/types";
|
import type { WSMessage, Session } from "../../../lib/types";
|
||||||
|
|
||||||
function renderSagi() {
|
function renderSagi() {
|
||||||
@@ -55,16 +54,15 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
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();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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(panel).toBeInTheDocument();
|
expect(within(panel).getByText("live")).toBeInTheDocument();
|
||||||
|
expect(within(panel).queryByRole("textbox")).toBeNull();
|
||||||
const input = within(panel).getByLabelText(/ask sagi anything/i);
|
expect(within(panel).queryByRole("button", { name: /new chat/i })).toBeNull();
|
||||||
fireEvent.change(input, { target: { value: "status" } });
|
fireEvent.click(within(panel).getByRole("button", { name: /close sagi/i }));
|
||||||
fireEvent.submit(input.closest("form")!);
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
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();
|
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();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish({
|
eventBus.publish({
|
||||||
@@ -102,7 +100,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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).getByText("Needs you")).toBeInTheDocument();
|
||||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).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"]);
|
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();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||||
const input = screen.getByLabelText("Pet name");
|
const input = screen.getByLabelText("Pet name");
|
||||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /pet mochi/i })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hands a question it cannot answer to the AI", () => {
|
it("a pat shows its reaction on the pet even while its window is open", () => {
|
||||||
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", () => {
|
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||||
@@ -187,14 +148,14 @@ describe("Sagi widget", () => {
|
|||||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
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();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish(sessionMsg("a", "active"));
|
eventBus.publish(sessionMsg("a", "active"));
|
||||||
eventBus.publish(sessionMsg("b", "active"));
|
eventBus.publish(sessionMsg("b", "active"));
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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.
|
// The "live" stat chip shows value 2 next to its label.
|
||||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||||
@@ -208,7 +169,7 @@ describe("Sagi widget", () => {
|
|||||||
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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 });
|
||||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||||
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/theirs/)).toBeNull();
|
expect(screen.queryByText(/theirs/)).toBeNull();
|
||||||
@@ -220,7 +181,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
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();
|
renderSagi();
|
||||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -228,7 +189,7 @@ describe("Sagi widget", () => {
|
|||||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
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", () => {
|
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||||
@@ -257,7 +218,7 @@ describe("Sagi widget", () => {
|
|||||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
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.
|
// No position was persisted because no real drag happened.
|
||||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file useChat.test.tsx
|
|
||||||
* @description `useChat`'s live side: a message from someone else raises a
|
|
||||||
* notice (sender, room) and bumps the room's unread count, plays the sound
|
|
||||||
* only with notifications on, and stays quiet for the room on screen and for
|
|
||||||
* the user's own messages; a `chat_read` frame moves that member's read mark
|
|
||||||
* ("Seen"); a group change is no unread message; with several tabs only the
|
|
||||||
* first to see a message plays its sound; nothing runs in local mode.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { eventBus } from "../../../lib/eventBus";
|
|
||||||
import type { ChatMessage } from "../../../lib/api";
|
|
||||||
|
|
||||||
const auth = vi.hoisted(() => ({
|
|
||||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
|
|
||||||
}));
|
|
||||||
const api = vi.hoisted(() => ({
|
|
||||||
chat: { rooms: vi.fn(), people: vi.fn() },
|
|
||||||
}));
|
|
||||||
const sound = vi.hoisted(() => ({ playSound: vi.fn() }));
|
|
||||||
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
|
|
||||||
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
|
|
||||||
vi.mock("../../../lib/notify", () => sound);
|
|
||||||
|
|
||||||
import { useChat } from "../useChat";
|
|
||||||
|
|
||||||
const room = {
|
|
||||||
id: 7,
|
|
||||||
kind: "group" as const,
|
|
||||||
name: "Team",
|
|
||||||
createdBy: 1,
|
|
||||||
createdAt: "2026-11-03T01:00:00Z",
|
|
||||||
lastMessageAt: null,
|
|
||||||
members: [1, 2],
|
|
||||||
lastMessage: null,
|
|
||||||
lastReadId: 0,
|
|
||||||
unread: 0,
|
|
||||||
};
|
|
||||||
const msg = (id: number, userId: number): ChatMessage => ({
|
|
||||||
id,
|
|
||||||
roomId: 7,
|
|
||||||
userId,
|
|
||||||
kind: "text",
|
|
||||||
body: `tin ${id}`,
|
|
||||||
createdAt: `2026-11-03T02:00:0${id}Z`,
|
|
||||||
files: [],
|
|
||||||
});
|
|
||||||
const frame = (m: ChatMessage) =>
|
|
||||||
eventBus.publish({ type: "chat_message", data: { roomId: 7, message: m }, timestamp: "t" });
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
localStorage.clear();
|
|
||||||
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
|
|
||||||
api.chat.rooms.mockResolvedValue({ rooms: [room] });
|
|
||||||
api.chat.people.mockResolvedValue({
|
|
||||||
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useChat", () => {
|
|
||||||
it("raises a notice for someone else's message in a room not on screen", async () => {
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
await waitFor(() => expect(result.current.people.length).toBe(1));
|
|
||||||
act(() => frame(msg(1, 2)));
|
|
||||||
expect(result.current.notice).toMatchObject({ roomId: 7, title: "An · Team", text: "tin 1" });
|
|
||||||
expect(result.current.unread).toBe(1);
|
|
||||||
expect(sound.playSound).not.toHaveBeenCalled(); // notifications off by default
|
|
||||||
});
|
|
||||||
|
|
||||||
it("plays the sound when notifications are on", async () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"agent-monitor-notifications",
|
|
||||||
JSON.stringify({ enabled: true, sound: "pop" })
|
|
||||||
);
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
|
||||||
act(() => frame(msg(2, 2)));
|
|
||||||
expect(sound.playSound).toHaveBeenCalledWith("pop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays quiet for the room on screen and for your own messages", async () => {
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
|
||||||
act(() => result.current.setActive(7));
|
|
||||||
act(() => frame(msg(3, 2)));
|
|
||||||
act(() => frame(msg(4, 1)));
|
|
||||||
expect(result.current.notice).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing in local mode", () => {
|
|
||||||
auth.state = { mode: "local" };
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
expect(result.current.enabled).toBe(false);
|
|
||||||
expect(api.chat.rooms).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves a member's read mark on a chat_read frame", async () => {
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
|
||||||
act(() =>
|
|
||||||
eventBus.publish({
|
|
||||||
type: "chat_read",
|
|
||||||
data: { roomId: 7, userId: 2, lastReadId: 9 },
|
|
||||||
timestamp: "t",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(result.current.rooms[0]!.reads?.[2]).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not count a group change as unread", async () => {
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
|
||||||
act(() => frame({ ...msg(5, 2), kind: "system", body: "renamed" }));
|
|
||||||
expect(result.current.unread).toBe(0);
|
|
||||||
expect(result.current.notice).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("plays a message's sound in one tab only", async () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"agent-monitor-notifications",
|
|
||||||
JSON.stringify({ enabled: true, sound: "pop" })
|
|
||||||
);
|
|
||||||
const a = renderHook(() => useChat());
|
|
||||||
const b = renderHook(() => useChat());
|
|
||||||
await waitFor(() => expect(a.result.current.rooms.length).toBe(1));
|
|
||||||
await waitFor(() => expect(b.result.current.rooms.length).toBe(1));
|
|
||||||
act(() => frame(msg(6, 2)));
|
|
||||||
expect(sound.playSound).toHaveBeenCalledTimes(1);
|
|
||||||
// Both still show the card.
|
|
||||||
expect(a.result.current.notice).not.toBeNull();
|
|
||||||
expect(b.result.current.notice).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,8 +6,7 @@
|
|||||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||||
* which made the whole desktop stutter. All motion is disabled under
|
* which made the whole desktop stutter. All motion is disabled under
|
||||||
* [data-reduced="1"] and prefers-reduced-motion. The blue dot counts unread
|
* [data-reduced="1"] and prefers-reduced-motion.
|
||||||
* chat messages from other users (server mode).
|
|
||||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -407,25 +406,6 @@
|
|||||||
outline: 2px solid #818cf8;
|
outline: 2px solid #818cf8;
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
/* Unread chat messages from other users: top-left, opposite the error dot. */
|
|
||||||
.sagi-chat-dot {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
min-width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
padding: 0 2px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: #3b82f6;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 2px solid #0c0c14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sagi-error-dot {
|
.sagi-error-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
@@ -549,13 +529,10 @@
|
|||||||
z-index: 42;
|
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
|
/* A tint of the theme's accent at the top fading into the surface, so it
|
||||||
colour: a tint of the accent at the top fading into the surface, so it
|
reads in every light and dark theme. */
|
||||||
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). */
|
|
||||||
.pet-chat {
|
.pet-chat {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
@@ -567,61 +544,6 @@
|
|||||||
.pet-chat-head {
|
.pet-chat-head {
|
||||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
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 ════════ */
|
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||||
|
|
||||||
|
|||||||
@@ -1,305 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file useChat.ts
|
|
||||||
* @description The pet window's chat between users (server mode only): the
|
|
||||||
* signed-in user's rooms (`GET /api/chat/rooms`), the people they can start a
|
|
||||||
* chat with (`GET /api/chat/people`, read once), and the total unread count
|
|
||||||
* for the badge on the pet. A `chat_message` frame updates its room's newest
|
|
||||||
* message and unread count in place and is handed to the open room's view
|
|
||||||
* (`onMessage`); a `chat_room` frame (made, renamed, members changed, left)
|
|
||||||
* re-reads the list. A message from someone else in a room that is not open
|
|
||||||
* on screen raises a `notice` (the pet shows it as a card that opens the
|
|
||||||
* room), plays the user's notification sound when notifications are on, and —
|
|
||||||
* while the tab is hidden and the browser allows it — shows a local system
|
|
||||||
* notification (never through the server's push relay, which would reach
|
|
||||||
* other users' devices). A room can be muted (no notice, no sound — its
|
|
||||||
* unread count still shows), kept per browser in `agenthub.chat.muted`; a
|
|
||||||
* `chat_deleted` frame drops a message from the open view and re-reads the
|
|
||||||
* list; a `chat_read` frame moves that member's read mark (`reads`, what the
|
|
||||||
* room shows as Seen). With several tabs open, only the first to see a
|
|
||||||
* message plays its sound / system notification (`agenthub.chat.notified`).
|
|
||||||
* Off in local mode — one person has no one to chat with.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
|
|
||||||
import { eventBus } from "../../lib/eventBus";
|
|
||||||
import { useAuth } from "../AuthGate";
|
|
||||||
import { loadNotifPrefs } from "../../lib/notifPrefs";
|
|
||||||
import { playSound } from "../../lib/notify";
|
|
||||||
import type { WSMessage } from "../../lib/types";
|
|
||||||
|
|
||||||
/** A new message to show on the pet. */
|
|
||||||
export type ChatNotice = { key: number; roomId: number; title: string; text: string };
|
|
||||||
|
|
||||||
export type ChatState = {
|
|
||||||
/** Server mode with a login: the chat exists. */
|
|
||||||
enabled: boolean;
|
|
||||||
me: number | null;
|
|
||||||
rooms: ChatRoom[];
|
|
||||||
people: ChatPerson[];
|
|
||||||
unread: number;
|
|
||||||
reload: () => Promise<void>;
|
|
||||||
/** A room changed here (opened, renamed, read): put it in the list now. */
|
|
||||||
upsert: (room: ChatRoom) => void;
|
|
||||||
/** The open room's view, told of each new message for it. */
|
|
||||||
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
|
|
||||||
/** Mark a room read locally (the view also tells the server). */
|
|
||||||
markRead: (roomId: number, upTo: number) => void;
|
|
||||||
/** The room on screen now (null: none, or the window closed) — no notice for it. */
|
|
||||||
setActive: (roomId: number | null) => void;
|
|
||||||
notice: ChatNotice | null;
|
|
||||||
dismissNotice: () => void;
|
|
||||||
/** The open room's view, told when a message of it is deleted. */
|
|
||||||
onDeleted: (fn: ((roomId: number, messageId: number) => void) | null) => void;
|
|
||||||
/** Read the people list again (New chat, a room change). */
|
|
||||||
reloadPeople: () => Promise<void>;
|
|
||||||
/** Rooms whose new messages raise no notice or sound. */
|
|
||||||
muted: Set<number>;
|
|
||||||
toggleMute: (roomId: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MUTED_KEY = "agenthub.chat.muted";
|
|
||||||
const NOTIFIED_KEY = "agenthub.chat.notified";
|
|
||||||
|
|
||||||
/** True for the first tab of this browser to claim message `id`'s sound / notification. */
|
|
||||||
function claimNotice(id: number): boolean {
|
|
||||||
try {
|
|
||||||
if (Number(localStorage.getItem(NOTIFIED_KEY) || 0) >= id) return false;
|
|
||||||
localStorage.setItem(NOTIFIED_KEY, String(id));
|
|
||||||
} catch {
|
|
||||||
// No storage: every tab plays it.
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function readMuted(): Set<number> {
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(localStorage.getItem(MUTED_KEY) || "[]");
|
|
||||||
return new Set(Array.isArray(v) ? v.filter((x) => Number.isInteger(x)) : []);
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a notice says of a message: its text, else its files. */
|
|
||||||
function preview(m: ChatMessage): string {
|
|
||||||
const text = m.body.trim().replace(/\s+/g, " ");
|
|
||||||
if (text) return text.length > 140 ? `${text.slice(0, 139)}…` : text;
|
|
||||||
return m.files.map((f) => `📎 ${f.name}`).join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A system notification from this tab only (the service worker's, else the page's). */
|
|
||||||
async function localNotification(title: string, body: string) {
|
|
||||||
try {
|
|
||||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
|
||||||
if ("serviceWorker" in navigator) {
|
|
||||||
const reg = await navigator.serviceWorker.getRegistration();
|
|
||||||
if (reg) {
|
|
||||||
await reg.showNotification(title, { body, icon: "/favicon.svg", tag: `chat-${title}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
new Notification(title, { body, icon: "/favicon.svg" });
|
|
||||||
} catch {
|
|
||||||
// A browser that refuses: the card on the pet still shows it.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChat(): ChatState {
|
|
||||||
const { state } = useAuth();
|
|
||||||
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
|
|
||||||
const enabled = me != null;
|
|
||||||
const [rooms, setRooms] = useState<ChatRoom[]>([]);
|
|
||||||
const [people, setPeople] = useState<ChatPerson[]>([]);
|
|
||||||
const listener = useRef<((m: ChatMessage) => void) | null>(null);
|
|
||||||
const active = useRef<number | null>(null);
|
|
||||||
const deletedListener = useRef<((roomId: number, messageId: number) => void) | null>(null);
|
|
||||||
const [muted, setMuted] = useState<Set<number>>(readMuted);
|
|
||||||
const mutedRef = useRef(muted);
|
|
||||||
mutedRef.current = muted;
|
|
||||||
const [notice, setNotice] = useState<ChatNotice | null>(null);
|
|
||||||
// Names for a notice, read inside the socket handler.
|
|
||||||
const lookup = useRef({ rooms, people });
|
|
||||||
lookup.current = { rooms, people };
|
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
|
||||||
if (!enabled) return;
|
|
||||||
try {
|
|
||||||
setRooms((await api.chat.rooms()).rooms);
|
|
||||||
} catch {
|
|
||||||
// The pet's chat with the AI still works; the list retries on the next frame.
|
|
||||||
}
|
|
||||||
}, [enabled]);
|
|
||||||
|
|
||||||
const reloadPeople = useCallback(async () => {
|
|
||||||
if (!enabled) return;
|
|
||||||
try {
|
|
||||||
setPeople((await api.chat.people()).people);
|
|
||||||
} catch {
|
|
||||||
// Names fall back until the next read.
|
|
||||||
}
|
|
||||||
}, [enabled]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
void reload();
|
|
||||||
void reloadPeople();
|
|
||||||
}, [enabled, reload, reloadPeople]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
const off = eventBus.subscribe((msg: WSMessage) => {
|
|
||||||
if (msg.type === "chat_room") {
|
|
||||||
void reload();
|
|
||||||
void reloadPeople();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === "chat_read") {
|
|
||||||
const d = msg.data as { roomId: number; userId?: number; lastReadId?: number };
|
|
||||||
if (d.userId == null || d.lastReadId == null) return;
|
|
||||||
setRooms((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
r.id === d.roomId
|
|
||||||
? {
|
|
||||||
...r,
|
|
||||||
reads: {
|
|
||||||
...r.reads,
|
|
||||||
[d.userId!]: Math.max(r.reads?.[d.userId!] ?? 0, d.lastReadId!),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: r
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === "chat_deleted") {
|
|
||||||
const d = msg.data as { roomId: number; messageId?: number };
|
|
||||||
if (d.messageId != null) deletedListener.current?.(d.roomId, d.messageId);
|
|
||||||
void reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type !== "chat_message") return;
|
|
||||||
const m = (msg.data as { message?: ChatMessage }).message;
|
|
||||||
if (!m) return;
|
|
||||||
let known = false;
|
|
||||||
setRooms((prev) => {
|
|
||||||
const next = prev.map((r) => {
|
|
||||||
if (r.id !== m.roomId) return r;
|
|
||||||
known = true;
|
|
||||||
const own = m.userId === me;
|
|
||||||
return {
|
|
||||||
...r,
|
|
||||||
lastMessage: m,
|
|
||||||
lastMessageAt: m.createdAt,
|
|
||||||
// A group change (system line) is not a message to read.
|
|
||||||
unread: own ? 0 : m.kind === "text" ? r.unread + 1 : r.unread,
|
|
||||||
lastReadId: own ? m.id : r.lastReadId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return next.sort((a, b) =>
|
|
||||||
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
// A room this list has not seen yet (added a moment ago): read it all.
|
|
||||||
if (!known) void reload();
|
|
||||||
listener.current?.(m);
|
|
||||||
if (m.userId === me || m.kind !== "text") return;
|
|
||||||
const hidden = document.visibilityState !== "visible";
|
|
||||||
if (active.current === m.roomId && !hidden) return;
|
|
||||||
if (mutedRef.current.has(m.roomId)) return;
|
|
||||||
const sender = lookup.current.people.find((p) => p.id === m.userId);
|
|
||||||
const from = sender ? sender.displayName || sender.username : "?";
|
|
||||||
const room = lookup.current.rooms.find((r) => r.id === m.roomId);
|
|
||||||
const title = room && room.kind === "group" ? `${from} · ${room.name || "…"}` : from;
|
|
||||||
const text = preview(m);
|
|
||||||
setNotice({ key: m.id, roomId: m.roomId, title, text });
|
|
||||||
// Several tabs open: only the first to see a message makes a sound or a
|
|
||||||
// system notification (message ids only grow).
|
|
||||||
if (!claimNotice(m.id)) return;
|
|
||||||
const prefs = loadNotifPrefs();
|
|
||||||
if (prefs.enabled) playSound(prefs.sound);
|
|
||||||
if (hidden) void localNotification(title, text);
|
|
||||||
});
|
|
||||||
// Reconnected: frames may have been missed.
|
|
||||||
const offConn = eventBus.onConnection((up) => {
|
|
||||||
if (up) void reload();
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
off();
|
|
||||||
offConn();
|
|
||||||
};
|
|
||||||
}, [enabled, me, reload, reloadPeople]);
|
|
||||||
|
|
||||||
const upsert = useCallback((room: ChatRoom) => {
|
|
||||||
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
|
|
||||||
}, []);
|
|
||||||
const onMessage = useCallback((fn: ((m: ChatMessage) => void) | null) => {
|
|
||||||
listener.current = fn;
|
|
||||||
}, []);
|
|
||||||
const markRead = useCallback(
|
|
||||||
(roomId: number, upTo: number) => {
|
|
||||||
setRooms((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
r.id === roomId
|
|
||||||
? {
|
|
||||||
...r,
|
|
||||||
unread: 0,
|
|
||||||
lastReadId: Math.max(r.lastReadId, upTo),
|
|
||||||
reads:
|
|
||||||
me != null ? { ...r.reads, [me]: Math.max(r.reads?.[me] ?? 0, upTo) } : r.reads,
|
|
||||||
}
|
|
||||||
: r
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[me]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setActive = useCallback((roomId: number | null) => {
|
|
||||||
active.current = roomId;
|
|
||||||
if (roomId != null) setNotice((n) => (n && n.roomId === roomId ? null : n));
|
|
||||||
}, []);
|
|
||||||
const dismissNotice = useCallback(() => setNotice(null), []);
|
|
||||||
const onDeleted = useCallback((fn: ((roomId: number, messageId: number) => void) | null) => {
|
|
||||||
deletedListener.current = fn;
|
|
||||||
}, []);
|
|
||||||
const toggleMute = useCallback((roomId: number) => {
|
|
||||||
setMuted((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(roomId)) next.delete(roomId);
|
|
||||||
else next.add(roomId);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(MUTED_KEY, JSON.stringify([...next]));
|
|
||||||
} catch {
|
|
||||||
// private mode: muted for this tab only
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
// A notice goes away by itself.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!notice) return;
|
|
||||||
const id = window.setTimeout(() => setNotice(null), 10000);
|
|
||||||
return () => window.clearTimeout(id);
|
|
||||||
}, [notice]);
|
|
||||||
|
|
||||||
const unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
|
|
||||||
return {
|
|
||||||
enabled,
|
|
||||||
me,
|
|
||||||
rooms,
|
|
||||||
people,
|
|
||||||
unread,
|
|
||||||
reload,
|
|
||||||
upsert,
|
|
||||||
onMessage,
|
|
||||||
markRead,
|
|
||||||
setActive,
|
|
||||||
notice,
|
|
||||||
dismissNotice,
|
|
||||||
onDeleted,
|
|
||||||
reloadPeople,
|
|
||||||
muted,
|
|
||||||
toggleMute,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,14 @@
|
|||||||
* the next option whose label starts with it. Focus returns to the trigger on
|
* the next option whose label starts with it. Focus returns to the trigger on
|
||||||
* every close except Tab.
|
* 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
|
* ## Consumers
|
||||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||||
* enum picker is needed.
|
* enum picker is needed.
|
||||||
@@ -73,7 +81,11 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
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
|
/** 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
|
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||||
@@ -95,6 +107,8 @@ export interface SelectOption<T extends string> {
|
|||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
/** Draw a rule above this option: it starts a group of its own. */
|
/** Draw a rule above this option: it starts a group of its own. */
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
|
/** The group this option belongs to: a heading is drawn where it changes. */
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for {@link Select}. */
|
/** Props for {@link Select}. */
|
||||||
@@ -111,6 +125,10 @@ export interface SelectProps<T extends string> {
|
|||||||
variant?: "field" | "ghost";
|
variant?: "field" | "ghost";
|
||||||
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
||||||
label?: string;
|
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,
|
disabled,
|
||||||
variant = "field",
|
variant = "field",
|
||||||
label,
|
label,
|
||||||
|
searchable,
|
||||||
|
searchLabel,
|
||||||
}: SelectProps<T>) {
|
}: SelectProps<T>) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
const ghost = variant === "ghost";
|
const ghost = variant === "ghost";
|
||||||
|
const canSearch = searchable ?? options.length > SEARCH_FROM;
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [openUp, setOpenUp] = useState(false);
|
const [openUp, setOpenUp] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||||
const typed = useRef({ text: "", at: 0 });
|
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 id = useId();
|
||||||
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
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.
|
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
setQuery("");
|
||||||
const idx = options.findIndex((o) => o.value === value);
|
const idx = options.findIndex((o) => o.value === value);
|
||||||
setActive(idx >= 0 ? idx : 0);
|
setActive(idx >= 0 ? idx : 0);
|
||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
@@ -159,14 +189,14 @@ export function Select<T extends string>({
|
|||||||
);
|
);
|
||||||
setOpenUp(below < 288 && above > below);
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
||||||
}, [open, active]);
|
}, [open, active, q]);
|
||||||
|
|
||||||
const close = (refocus = true) => {
|
const close = (refocus = true) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -185,8 +215,12 @@ export function Select<T extends string>({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const onListKey = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||||
const last = options.length - 1;
|
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> = {
|
const move: Record<string, (a: number) => number> = {
|
||||||
ArrowDown: (a) => Math.min(last, a + 1),
|
ArrowDown: (a) => Math.min(last, a + 1),
|
||||||
ArrowUp: (a) => Math.max(0, a - 1),
|
ArrowUp: (a) => Math.max(0, a - 1),
|
||||||
@@ -200,7 +234,7 @@ export function Select<T extends string>({
|
|||||||
setActive(move[e.key]!);
|
setActive(move[e.key]!);
|
||||||
} else if (e.key === "Enter" || e.key === " ") {
|
} else if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const opt = options[active];
|
const opt = shown[active];
|
||||||
if (opt) choose(opt);
|
if (opt) choose(opt);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
// Stop here: a Select inside a dialog must not close the dialog too.
|
// Stop here: a Select inside a dialog must not close the dialog too.
|
||||||
@@ -209,7 +243,7 @@ export function Select<T extends string>({
|
|||||||
close();
|
close();
|
||||||
} else if (e.key === "Tab") {
|
} else if (e.key === "Tab") {
|
||||||
close(false);
|
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
|
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
||||||
// character cycles through the options that start with it.
|
// character cycles through the options that start with it.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -257,27 +291,68 @@ export function Select<T extends string>({
|
|||||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" aria-hidden />
|
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
id={`${id}-list`}
|
id={`${id}-list`}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
|
||||||
onKeyDown={onListKey}
|
onKeyDown={onListKey}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
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="min-h-0 flex-1 overflow-auto py-1 outline-none"
|
||||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{options.map((opt, idx) => {
|
{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 isSelected = opt.value === value;
|
||||||
const isActive = idx === active;
|
const isActive = idx === active;
|
||||||
|
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
|
||||||
return (
|
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
|
<div
|
||||||
key={opt.value || "__default__"}
|
|
||||||
id={optionId(idx)}
|
id={optionId(idx)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
@@ -307,9 +382,11 @@ export function Select<T extends string>({
|
|||||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -559,6 +559,8 @@ export function WebhookSettings() {
|
|||||||
headerRows: [],
|
headerRows: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// A short, fixed list of providers: no search box.
|
||||||
|
searchable={false}
|
||||||
options={providers.map((p) => ({ value: p.type, label: p.label }))}
|
options={providers.map((p) => ({ value: p.type, label: p.label }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||||||
vi.stubGlobal("innerHeight", 2000);
|
vi.stubGlobal("innerHeight", 2000);
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full");
|
||||||
vi.unstubAllGlobals();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
|||||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||||
import { restoreShelf } from "./LaneShelves";
|
import { restoreShelf } from "./LaneShelves";
|
||||||
import { BranchesButton } from "./git/BranchesButton";
|
import { BranchesButton } from "./git/BranchesButton";
|
||||||
|
import { Select } from "../Select";
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||||
@@ -526,33 +527,32 @@ export function LaneGitBar({
|
|||||||
|
|
||||||
{switching && (
|
{switching && (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<select
|
{/* A combobox once the list is long (Select's search box, #88):
|
||||||
aria-label={t("gitBar.switchLabel")}
|
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}
|
value={picked}
|
||||||
disabled={!!busy}
|
disabled={!!busy}
|
||||||
onChange={(e) => setPicked(e.target.value)}
|
onChange={setPicked}
|
||||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
options={[
|
||||||
>
|
{ value: "", label: t("gitBar.switchLabel") },
|
||||||
<option value="">{t("gitBar.switchLabel")}</option>
|
...(branches || []).map((b) => {
|
||||||
{(branches || []).map((b) => {
|
|
||||||
const held = heldBy.get(b);
|
const held = heldBy.get(b);
|
||||||
return (
|
const note =
|
||||||
<option key={b} value={b}>
|
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
|
||||||
{b}
|
return { value: b, label: `${b}${note}` };
|
||||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
}),
|
||||||
</option>
|
...remoteBranches.map((b) => ({
|
||||||
);
|
value: b,
|
||||||
})}
|
label: b,
|
||||||
{remoteBranches.length > 0 && (
|
group: t("gitBar.remoteBranches"),
|
||||||
<optgroup label={t("gitBar.remoteBranches")}>
|
})),
|
||||||
{remoteBranches.map((b) => (
|
]}
|
||||||
<option key={`remote:${b}`} value={b}>
|
/>
|
||||||
{b}
|
</div>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<Btn
|
<Btn
|
||||||
label={t("gitBar.switchApply")}
|
label={t("gitBar.switchApply")}
|
||||||
busy={busy === "switch"}
|
busy={busy === "switch"}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function HomeBody({
|
|||||||
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||||
const profile = proxyCapable
|
const profile = proxyCapable
|
||||||
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
? (proxies?.find((r) => r.name === proxy && !r.builtin && !r.company) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const proxyModels: ModelChoice[] | undefined = profile
|
const proxyModels: ModelChoice[] | undefined = profile
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -171,7 +171,9 @@ beforeEach(() => {
|
|||||||
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
||||||
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
||||||
vi.mocked(h.pick).mockReset();
|
vi.mocked(h.pick).mockReset();
|
||||||
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
|
vi.mocked(h.pickPlan)
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (_t, op, shas) => ({
|
||||||
available: true,
|
available: true,
|
||||||
op,
|
op,
|
||||||
commits: shas.map((sha) => ({
|
commits: shas.map((sha) => ({
|
||||||
@@ -538,7 +540,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
await screen.findByTitle("only-on-thing.ts");
|
await screen.findByTitle("only-on-thing.ts");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
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 user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
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(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
||||||
await user.click(await screen.findByTestId("pick-confirm"));
|
await user.click(await screen.findByTestId("pick-confirm"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
|
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", ["a".repeat(40)])
|
||||||
"a".repeat(40),
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { LaneGitBar } from "../LaneGitBar";
|
import { LaneGitBar } from "../LaneGitBar";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import { confirmDialog } from "../../../lib/confirm";
|
import { confirmDialog } from "../../../lib/confirm";
|
||||||
import type { LaneGitFacts } from "../../../lib/types";
|
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.
|
// 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/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
@@ -210,7 +219,7 @@ describe("LaneGitBar", () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
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" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
// Nothing is sent before the user picks what happens to the changes.
|
// Nothing is sent before the user picks what happens to the changes.
|
||||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
@@ -235,7 +244,7 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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: "Switch branch" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||||
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
||||||
@@ -258,7 +267,7 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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: "Switch branch" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -291,7 +300,7 @@ describe("LaneGitBar", () => {
|
|||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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: "Switch branch" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
|
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()} />);
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
const select = await screen.findByLabelText("Switch branch…");
|
await screen.findByRole("button", { name: "Switch branch…" });
|
||||||
await user.selectOptions(select, "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -332,11 +341,42 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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: "Switch branch" }));
|
||||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
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 () => {
|
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({
|
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
@@ -355,11 +395,12 @@ describe("LaneGitBar", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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(
|
expect(
|
||||||
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
|
screen.getByText("On the remote — switching creates the local branch")
|
||||||
).toBeInTheDocument();
|
).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.
|
// 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: "Delete" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: /New workspace here/ })).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" }));
|
await user.click(screen.getByRole("button", { name: "New workspace here" }));
|
||||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||||
});
|
});
|
||||||
@@ -413,18 +454,18 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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" });
|
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
|
||||||
|
|
||||||
// This lane's own branch: its worktree is this lane.
|
// This lane's own branch: its worktree is this lane.
|
||||||
await user.selectOptions(select, "feat/thing");
|
await pickBranch(user, "feat/thing");
|
||||||
expect(newLane()).toBeDisabled();
|
expect(newLane()).toBeDisabled();
|
||||||
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
// 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()).toBeDisabled();
|
||||||
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||||
|
|
||||||
await user.selectOptions(select, "feat/free");
|
await pickBranch(user, "feat/free");
|
||||||
expect(newLane()).toBeEnabled();
|
expect(newLane()).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,13 +484,15 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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()} />);
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
||||||
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
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 () => {
|
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()} />);
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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));
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||||
// Lane 3's answer lands late: its worktree held `development`.
|
// Lane 3's answer lands late: its worktree held `development`.
|
||||||
resolveOld({
|
resolveOld({
|
||||||
@@ -485,7 +528,7 @@ describe("LaneGitBar", () => {
|
|||||||
current: "feat/thing",
|
current: "feat/thing",
|
||||||
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
||||||
});
|
});
|
||||||
await user.selectOptions(select, "development");
|
await pickBranch(user, "development");
|
||||||
const newLane = screen.getByRole("button", { name: "New workspace here" });
|
const newLane = screen.getByRole("button", { name: "New workspace here" });
|
||||||
expect(newLane).toBeEnabled();
|
expect(newLane).toBeEnabled();
|
||||||
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||||
@@ -572,7 +615,7 @@ describe("merge (issue #45)", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
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 user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* @file RemoteDevices.tsx
|
||||||
|
* @description The hub's view of remote clients (server mode, server/hub/):
|
||||||
|
* a user's enrolled machines with Revoke (the viewer's own password again —
|
||||||
|
* the server checks it 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 { isSubmitEnter } from "../../lib/keys";
|
||||||
|
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) {
|
||||||
|
const d = s.deviceName;
|
||||||
|
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}</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),
|
||||||
|
ip: d.lastIp || "?",
|
||||||
|
})}
|
||||||
|
</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 [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const submit = async () => {
|
||||||
|
if (!password || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.remoteHub.revoke(device.id, password);
|
||||||
|
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}
|
||||||
|
disabled={!password}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-2"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">
|
||||||
|
{t("companyHub.yourPassword")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input mt-1 w-full text-sm"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("companyHub.yourPassword")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
@@ -124,6 +124,11 @@ const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v ==
|
|||||||
const OUTPUT_FETCH_MS = 1000;
|
const OUTPUT_FETCH_MS = 1000;
|
||||||
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||||
const OUTPUT_BUSY_MS = 2000;
|
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 SIDE_KEY = "agenthub.chat.panelWidth";
|
||||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
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}`;
|
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
|
* 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. */
|
* (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.
|
// 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 {
|
export function contextWindow(model: string | null): number | null {
|
||||||
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||||
if (!model?.startsWith("claude-")) return null;
|
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
|
/** How full the context is: a ring and a percentage, amber then red as
|
||||||
* Claude Code's auto-compact comes near. */
|
* Claude Code's auto-compact comes near. */
|
||||||
function ContextMeter({
|
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||||
tokens,
|
|
||||||
model,
|
|
||||||
}: {
|
|
||||||
tokens: number;
|
|
||||||
model: string | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const size = contextWindow(model);
|
const size = contextWindow(model);
|
||||||
if (!size || !tokens) return null;
|
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 scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||||
const text = promptWithImages(prompt, images);
|
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
|
// 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.
|
// waits on the user from a Notification (a permission prompt) to the next hook.
|
||||||
const [working, setWorking] = useState(false);
|
const [working, setWorking] = useState(false);
|
||||||
const [waiting, setWaiting] = useState(false);
|
const [waiting, setWaiting] = useState(false);
|
||||||
// Stop presses, for the conversation's live line (issue #74).
|
// Stop presses, for the conversation's live line (issue #74).
|
||||||
const [stops, setStops] = useState(0);
|
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
|
api.agents
|
||||||
.list({ session_id: sessionId, status: "working" })
|
.list({ session_id: sessionId, status: "working" })
|
||||||
// With session_id the server answers every agent of the session, its
|
// With session_id the server answers every agent of the session, its
|
||||||
// status filter ignored: check the status here.
|
// status filter ignored: check the status here.
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
|
if (seq !== readSeq.current) return;
|
||||||
const main = r.agents.filter((a) => a.type === "main");
|
const main = r.agents.filter((a) => a.type === "main");
|
||||||
// A fresh start, resume or `/clear` leaves main "working" at an
|
// A fresh start, resume or `/clear` leaves main "working" at an
|
||||||
// empty prompt (SessionStart's flag) until the first prompt.
|
// 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"));
|
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||||
})
|
})
|
||||||
.catch(() => {}),
|
.catch(() => {})
|
||||||
[sessionId]
|
|
||||||
);
|
);
|
||||||
|
}, [sessionId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void readWorking();
|
void readWorking();
|
||||||
}, [readWorking]);
|
}, [readWorking]);
|
||||||
@@ -714,7 +752,20 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
}, 700);
|
}, 700);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [runId, onScreen]);
|
}, [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;
|
const asking = waiting || menu !== null;
|
||||||
// Gone until the pane redraws: no second press on a stale menu.
|
// Gone until the pane redraws: no second press on a stale menu.
|
||||||
const choose = (index: number) => {
|
const choose = (index: number) => {
|
||||||
@@ -762,8 +813,6 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
() => showTerminal(runId, sessionId, run.tool),
|
() => showTerminal(runId, sessionId, run.tool),
|
||||||
[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 —
|
// 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.
|
// not the workspace's Changes panel a plain file link goes to.
|
||||||
const openArtifact = useCallback(
|
const openArtifact = useCallback(
|
||||||
@@ -812,6 +861,8 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
if (!text || uploading) return;
|
if (!text || uploading) return;
|
||||||
const out = promptForPane(prompt, images, claude);
|
const out = promptForPane(prompt, images, claude);
|
||||||
if (!(await command(out.text, out.files))) return;
|
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);
|
setFollow((n) => n + 1);
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
* 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
|
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||||
* for the next session (`agenthub.session.proxy`; the pre-rename
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -39,14 +42,30 @@ export function useSessionProxy(): [string, (v: string) => void] {
|
|||||||
return [value, set];
|
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 {
|
export function useAiProxies(): AiProxy[] | null {
|
||||||
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
api.aiProxies
|
// A hub (server mode) answers 404 here, and a failure only hides the entry.
|
||||||
.list()
|
const enrolled = Promise.resolve()
|
||||||
.then((r) => alive && setProxies(r.items))
|
.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([]));
|
.catch(() => alive && setProxies([]));
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
@@ -82,12 +101,19 @@ export function ProxyPicker({
|
|||||||
// A remembered profile that has since been deleted falls back to native,
|
// 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.
|
// or the start would be refused for a proxy the user cannot see.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
|
if (!proxies || value === "native") return;
|
||||||
}, [proxies, value, onChange]);
|
const pick = proxies.find((r) => r.name === value);
|
||||||
|
if (!pick || (pick.company && tool !== "claude")) onChange("native");
|
||||||
|
}, [proxies, value, onChange, tool]);
|
||||||
|
|
||||||
const options =
|
const options =
|
||||||
capable && proxies?.length
|
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 }];
|
: [{ value: "native", label: native }];
|
||||||
return (
|
return (
|
||||||
<div data-testid="proxy-picker">
|
<div data-testid="proxy-picker">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 { api } from "../../../lib/api";
|
||||||
import {
|
import {
|
||||||
answerRunMenu,
|
answerRunMenu,
|
||||||
@@ -33,16 +33,21 @@ import {
|
|||||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||||
import { eventBus } from "../../../lib/eventBus";
|
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", () => ({
|
vi.mock("../../conversation/ConversationView", () => ({
|
||||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||||
ConversationView: ({
|
ConversationView: ({
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onNotStarted,
|
onNotStarted,
|
||||||
|
onMessages,
|
||||||
}: {
|
}: {
|
||||||
onOpenFile?: (path: string) => void;
|
onOpenFile?: (path: string) => void;
|
||||||
onNotStarted?: (notStarted: boolean) => void;
|
onNotStarted?: (notStarted: boolean) => void;
|
||||||
|
onMessages?: (messages: never[]) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="conversation">
|
<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-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||||
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||||
{/* The session has no row yet: the CLI has not started it. */}
|
{/* The session has no row yet: the CLI has not started it. */}
|
||||||
@@ -215,12 +220,51 @@ describe("PaneChat — Stop", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(5000);
|
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());
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
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 () => {
|
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.
|
// omp, Cursor or Codex without its hooks: only the terminal knows.
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
@@ -233,6 +277,10 @@ describe("PaneChat — Stop", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(800);
|
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());
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
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 () => {
|
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
vi.mocked(readRunBusy).mockReturnValue(null);
|
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||||
@@ -261,6 +335,10 @@ describe("PaneChat — Stop", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(2800);
|
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());
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -710,3 +788,24 @@ describe("PaneChat — a Codex terminal", () => {
|
|||||||
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
|
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() },
|
aiProxies: { list: vi.fn() },
|
||||||
|
remoteClient: { status: vi.fn() },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -40,6 +41,55 @@ beforeEach(() => {
|
|||||||
vi.mocked(api.aiProxies.list)
|
vi.mocked(api.aiProxies.list)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ items: [native, nine] } as never);
|
.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", () => {
|
describe("ProxyPicker", () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
|
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
|
||||||
* admin's server-wide switch that allows or blocks installing a shared skill
|
* admin's server-wide switches: installing a shared skill or plugin from the
|
||||||
* or plugin from the Hub into a user's own account (`GET/PUT
|
* Hub into a user's own account, and asking Claude to save a viewable copy of
|
||||||
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
* 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`).
|
* or for anyone the server does not let manage it (`canManage`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -24,9 +24,10 @@ export function ArtifactsHubSettings() {
|
|||||||
.catch(() => setSettings(null));
|
.catch(() => setSettings(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!settings?.serverMode || !settings.canManage) return null;
|
if (!settings?.canManage) return null;
|
||||||
return (
|
return (
|
||||||
<SettingsSection id="artifacts-hub" title={t("title")}>
|
<SettingsSection id="artifacts-hub" title={t("title")}>
|
||||||
|
{settings.serverMode && (
|
||||||
<SettingsSwitchRow
|
<SettingsSwitchRow
|
||||||
label={t("installsSwitch")}
|
label={t("installsSwitch")}
|
||||||
description={t("installsSwitchHint")}
|
description={t("installsSwitchHint")}
|
||||||
@@ -40,6 +41,20 @@ export function ArtifactsHubSettings() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("exportsSwitch")}
|
||||||
|
description={t("exportsSwitchHint")}
|
||||||
|
checked={settings.exportsEnabled !== false}
|
||||||
|
onChange={async (v) => {
|
||||||
|
try {
|
||||||
|
const r = await api.artifacts.setExportsEnabled(v);
|
||||||
|
setSettings({ ...settings, exportsEnabled: r.exportsEnabled });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export function BackupSettings() {
|
|||||||
onChange={(v) => setHour(Number(v))}
|
onChange={(v) => setHour(Number(v))}
|
||||||
label={t("backup.hour")}
|
label={t("backup.hour")}
|
||||||
disabled={!state || owned?.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` }))}
|
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* @file CompanyHubSettings.tsx
|
||||||
|
* @description Settings → AI tools → Company hub (docs/plans/remote-clients).
|
||||||
|
* On a LOCAL install: enroll this machine with the company hub (hub address +
|
||||||
|
* the user's hub password, which goes to the hub once and is not kept — the
|
||||||
|
* same as `agenthub remote login`), what is and is not sent, the
|
||||||
|
* Company-account sessions sent and when, Send now and Log out
|
||||||
|
* (`/api/remote-client`). On the HUB (server mode, signed in): the user's own
|
||||||
|
* enrolled machines with Revoke, their Company-account sessions, and who
|
||||||
|
* (admin/analyst) opened their sessions (`/api/remote/*`).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Building2, LogOut, Upload } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api, type RemoteClientStatus } from "../../lib/api";
|
||||||
|
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||||
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function CompanyHubSettings() {
|
||||||
|
const { local } = usePermissions();
|
||||||
|
return local ? <LocalHub /> : <HubSide />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalHub() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [st, setSt] = useState<RemoteClientStatus | null>(null);
|
||||||
|
const [hub, setHub] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api.remoteClient
|
||||||
|
.status()
|
||||||
|
.then(setSt)
|
||||||
|
.catch(() => setSt(null));
|
||||||
|
}, []);
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<void>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const enroll = () =>
|
||||||
|
run(async () => {
|
||||||
|
setSt(await api.remoteClient.login({ hub, username, password }));
|
||||||
|
setPassword("");
|
||||||
|
toast.success(t("companyHub.enrolledToast"));
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
const logout = async () => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("companyHub.logoutTitle"),
|
||||||
|
message: t("companyHub.logoutHint"),
|
||||||
|
confirmLabel: t("companyHub.logout"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
await run(async () => {
|
||||||
|
await api.remoteClient.logout();
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const sendNow = () =>
|
||||||
|
run(async () => {
|
||||||
|
const r = await api.remoteClient.upload();
|
||||||
|
toast.success(
|
||||||
|
t("companyHub.sentToast", { bytes: formatBytes(r.bytes), sessions: r.sessions })
|
||||||
|
);
|
||||||
|
if (r.errors.length) setError(r.errors.map((e) => e.message).join("\n"));
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!st) return null;
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="company-hub"
|
||||||
|
title={t("companyHub.title")}
|
||||||
|
description={t("companyHub.description")}
|
||||||
|
>
|
||||||
|
<ul className="mb-3 list-disc space-y-0.5 pl-5 text-[11px] text-fg-muted">
|
||||||
|
<li>{t("companyHub.ruleSent")}</li>
|
||||||
|
<li>{t("companyHub.ruleNotSent")}</li>
|
||||||
|
<li>{t("companyHub.ruleKey")}</li>
|
||||||
|
<li>{t("companyHub.ruleOffice")}</li>
|
||||||
|
</ul>
|
||||||
|
{st.enrolled ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Building2 className="h-4 w-4 text-accent" />
|
||||||
|
<span className="text-fg-primary">
|
||||||
|
{t("companyHub.enrolledAs", { hub: st.hub, user: st.user?.username || "?" })}
|
||||||
|
</span>
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
· {st.device?.name} ·{" "}
|
||||||
|
{t("companyHub.validUntil", { time: formatDateTime(st.device?.expiresAt || "") })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary h-8 text-xs"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void sendNow()}
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
{t("companyHub.sendNow")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost h-8 text-xs"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void logout()}
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
|
{t("companyHub.logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">
|
||||||
|
{t("companyHub.sentTitle")}
|
||||||
|
</h4>
|
||||||
|
{st.uploads && st.uploads.length > 0 ? (
|
||||||
|
<ul
|
||||||
|
className="divide-y divide-border rounded-lg border border-border"
|
||||||
|
data-testid="company-uploads"
|
||||||
|
>
|
||||||
|
{st.uploads.map((u) => (
|
||||||
|
<li
|
||||||
|
key={u.sessionId}
|
||||||
|
className="flex flex-wrap items-center gap-2 px-3 py-1.5 text-[11px]"
|
||||||
|
>
|
||||||
|
<Link to={`/sessions/${u.sessionId}`} className="text-accent hover:underline">
|
||||||
|
{u.title || u.sessionId.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
<span className="truncate font-mono text-fg-muted">{u.cwd}</span>
|
||||||
|
<span className="ml-auto text-fg-muted">
|
||||||
|
{u.lastSentAt
|
||||||
|
? t("companyHub.sentLine", {
|
||||||
|
bytes: formatBytes(u.bytes || 0),
|
||||||
|
time: formatDateTime(u.lastSentAt),
|
||||||
|
})
|
||||||
|
: t("companyHub.notSentYet")}
|
||||||
|
{u.ended ? ` · ${t("companyHub.ended")}` : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-fg-muted">{t("companyHub.nothingSent")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="grid max-w-md gap-2"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e) && hub && username && password) {
|
||||||
|
e.preventDefault();
|
||||||
|
void enroll();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{st.expired && (
|
||||||
|
<p className="text-xs text-status-warning">{t("companyHub.expiredHint")}</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className="input text-sm"
|
||||||
|
placeholder="http://10.1.13.7:4820"
|
||||||
|
value={hub}
|
||||||
|
onChange={(e) => setHub(e.target.value)}
|
||||||
|
aria-label={t("companyHub.hubAddress")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input text-sm"
|
||||||
|
placeholder={t("companyHub.username")}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
aria-label={t("companyHub.username")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input text-sm"
|
||||||
|
placeholder={t("companyHub.password")}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("companyHub.password")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary h-8 w-fit text-xs"
|
||||||
|
disabled={busy || !hub || !username || !password}
|
||||||
|
onClick={() => void enroll()}
|
||||||
|
>
|
||||||
|
{t("companyHub.enroll")}
|
||||||
|
</button>
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("companyHub.cliHint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-2 whitespace-pre-line text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HubSide() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [audit, setAudit] = useState<
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
at: string;
|
||||||
|
sessionId: string;
|
||||||
|
reader: string | null;
|
||||||
|
readerDisplay: string | null;
|
||||||
|
}[]
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
api.remoteHub
|
||||||
|
.audit()
|
||||||
|
.then((r) => setAudit(r.entries))
|
||||||
|
.catch(() => setAudit(null));
|
||||||
|
}, []);
|
||||||
|
// A hub without the remote routes (an older server) answers 404: show nothing.
|
||||||
|
if (audit === null) return null;
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="company-hub"
|
||||||
|
title={t("companyHub.hubTitle")}
|
||||||
|
description={t("companyHub.hubDescription")}
|
||||||
|
>
|
||||||
|
<RemoteDevices canRevoke />
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">{t("companyHub.whoRead")}</h4>
|
||||||
|
{audit.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("companyHub.nobodyRead")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5 text-[11px]" data-testid="company-audit">
|
||||||
|
{audit.map((a) => (
|
||||||
|
<li key={a.id} className="text-fg-muted">
|
||||||
|
{formatDateTime(a.at)} ·{" "}
|
||||||
|
<span className="text-fg-primary">{a.readerDisplay || a.reader || "?"}</span> ·{" "}
|
||||||
|
<Link to={`/sessions/${a.sessionId}`} className="text-accent hover:underline">
|
||||||
|
{a.sessionId.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -222,6 +222,8 @@ export function ReportSettings() {
|
|||||||
value={String(form.value.hour)}
|
value={String(form.value.hour)}
|
||||||
onChange={(v) => set({ hour: Number(v) })}
|
onChange={(v) => set({ hour: Number(v) })}
|
||||||
label={t("reports.hour")}
|
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` }))}
|
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +38,12 @@ import {
|
|||||||
type ToolProfilesTool,
|
type ToolProfilesTool,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
|
import {
|
||||||
|
profileKey,
|
||||||
|
readHiddenProfiles,
|
||||||
|
toggleHidden,
|
||||||
|
writeHiddenProfiles,
|
||||||
|
} from "../../lib/hiddenProfilesStorage";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
@@ -129,7 +135,7 @@ export function ToolProfilesSettings() {
|
|||||||
<PoolLine count={poolCount(current.id)!} />
|
<PoolLine count={poolCount(current.id)!} />
|
||||||
)}
|
)}
|
||||||
{current.id === "claude" ? (
|
{current.id === "claude" ? (
|
||||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} local={local} />
|
||||||
) : (
|
) : (
|
||||||
<OtherProfiles
|
<OtherProfiles
|
||||||
key={current.id}
|
key={current.id}
|
||||||
@@ -475,7 +481,7 @@ function ActiveBadge() {
|
|||||||
// save button only appears while the live account is unsaved, because one
|
// save button only appears while the live account is unsaved, because one
|
||||||
// account is one profile.
|
// account is one profile.
|
||||||
|
|
||||||
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||||
@@ -492,6 +498,34 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||||
const autoFetched = useRef<Set<string>>(new Set());
|
const autoFetched = useRef<Set<string>>(new Set());
|
||||||
const ctx = useContextMenu<CcProfile>();
|
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);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -540,10 +574,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
if (!items) return;
|
if (!items) return;
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
if (autoFetched.current.has(p.name)) continue;
|
if (autoFetched.current.has(p.name)) continue;
|
||||||
|
if (!reveal && isHidden(p)) continue;
|
||||||
autoFetched.current.add(p.name);
|
autoFetched.current.add(p.name);
|
||||||
void checkUsage(p.name);
|
void checkUsage(p.name);
|
||||||
}
|
}
|
||||||
}, [items, checkUsage]);
|
}, [items, checkUsage, isHidden, reveal]);
|
||||||
|
|
||||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||||
/** How many saved profiles hold the same account as `p`. */
|
/** How many saved profiles hold the same account as `p`. */
|
||||||
@@ -587,7 +622,7 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
<span>
|
<span>
|
||||||
{t("profiles.mismatch", {
|
{t("profiles.mismatch", {
|
||||||
config: mismatchEmail,
|
config: mismatchEmail,
|
||||||
owner: active?.email || active?.accountUuid || "?",
|
owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -603,7 +638,9 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||||
{t("profiles.activeLabel")}
|
{t("profiles.activeLabel")}
|
||||||
</div>
|
</div>
|
||||||
{active ? (
|
{active && activeHidden ? (
|
||||||
|
<div className="text-sm text-fg-muted">—</div>
|
||||||
|
) : active ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-fg-primary">
|
<div className="text-sm text-fg-primary">
|
||||||
{active.email || active.displayName || active.accountUuid}
|
{active.email || active.displayName || active.accountUuid}
|
||||||
@@ -631,17 +668,18 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items === null && !error && <SkeletonRows n={3} />}
|
{items === null && !error && <SkeletonRows n={3} />}
|
||||||
{items !== null && items.length === 0 && (
|
{items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
|
||||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
<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")}
|
{t("profiles.empty")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{items?.map((p) => (
|
{items?.filter((p) => reveal || !isHidden(p)).map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.name}
|
key={p.name}
|
||||||
data-testid={`profile-row-${p.name}`}
|
data-testid={`profile-row-${p.name}`}
|
||||||
onContextMenu={(e) => ctx.open(e, p)}
|
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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<LabelEdit
|
<LabelEdit
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
|
|||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../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);
|
const settings = vi.mocked(api.artifacts.settings);
|
||||||
@@ -32,15 +34,23 @@ describe("ArtifactsHubSettings", () => {
|
|||||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
|
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 });
|
settings.mockResolvedValue({ ...base, canManage: false });
|
||||||
const { container, unmount } = render(<ArtifactsHubSettings />);
|
const { container, unmount } = render(<ArtifactsHubSettings />);
|
||||||
await waitFor(() => expect(settings).toHaveBeenCalled());
|
await waitFor(() => expect(settings).toHaveBeenCalled());
|
||||||
expect(container).toBeEmptyDOMElement();
|
expect(container).toBeEmptyDOMElement();
|
||||||
unmount();
|
unmount();
|
||||||
settings.mockResolvedValue({ ...base, serverMode: false });
|
settings.mockResolvedValue({ ...base, serverMode: false, exportsEnabled: true });
|
||||||
const r = render(<ArtifactsHubSettings />);
|
vi.mocked(api.artifacts.setExportsEnabled).mockResolvedValue({
|
||||||
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
|
installsEnabled: true,
|
||||||
expect(r.container).toBeEmptyDOMElement();
|
exportsEnabled: false,
|
||||||
|
});
|
||||||
|
render(<ArtifactsHubSettings />);
|
||||||
|
const sw = await screen.findByRole("switch", {
|
||||||
|
name: "Keep a viewable copy of Docs and Slides",
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("switch", { name: "Allow installing from the Hub" })).toBeNull();
|
||||||
|
fireEvent.click(sw);
|
||||||
|
await waitFor(() => expect(api.artifacts.setExportsEnabled).toHaveBeenCalledWith(false));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* @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 (their password again)
|
||||||
|
* 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() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 with their password, 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);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await show();
|
||||||
|
await waitFor(() => expect(screen.getByText("mia-pc")).toBeInTheDocument());
|
||||||
|
expect(screen.getByTestId("company-audit")).toHaveTextContent("Boss");
|
||||||
|
await user.click(screen.getByRole("button", { name: /^(revoke|thu hồi)$/i }));
|
||||||
|
await user.type(screen.getByLabelText(/your password|mật khẩu của bạn/i), "my-pass-1");
|
||||||
|
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, "my-pass-1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,6 +68,7 @@ async function show() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
perms.local = true;
|
perms.local = true;
|
||||||
m.cc.profiles.mockResolvedValue({
|
m.cc.profiles.mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
@@ -137,6 +138,34 @@ describe("ToolProfilesSettings — Claude", () => {
|
|||||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
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("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", () => {
|
describe("ToolProfilesSettings — other tools", () => {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { AiToolIcon } from "../AiToolIcon";
|
|||||||
import { SessionStatusBadge } from "../StatusBadge";
|
import { SessionStatusBadge } from "../StatusBadge";
|
||||||
import { MiniChart } from "../admin/MiniChart";
|
import { MiniChart } from "../admin/MiniChart";
|
||||||
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
||||||
|
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
|
||||||
const POOL_LABEL: Record<PoolTool, string> = {
|
const POOL_LABEL: Record<PoolTool, string> = {
|
||||||
claude: "Claude Code",
|
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 }) {
|
export function UserDetail({ userId }: { userId: number }) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const { label: toolLabel } = useAiTools();
|
const { label: toolLabel } = useAiTools();
|
||||||
const [data, setData] = useState<UserDetails | null>(null);
|
const [data, setData] = useState<UserDetails | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -289,6 +292,12 @@ export function UserDetail({ userId }: { userId: number }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</Panel>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"status_duration": "Stuck agent",
|
"status_duration": "Stuck agent",
|
||||||
"token_threshold": "Token threshold",
|
"token_threshold": "Token threshold",
|
||||||
"cost_threshold": "Cost threshold",
|
"cost_threshold": "Cost threshold",
|
||||||
"budget": "Spend budget"
|
"budget": "Spend budget",
|
||||||
|
"remote_unmonitored": "Company account outside AgentHub"
|
||||||
},
|
},
|
||||||
"ruleTypeHints": {
|
"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).",
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"ruleDesc": {
|
||||||
"eventPattern": "Matches {{pattern}}",
|
"eventPattern": "Matches {{pattern}}",
|
||||||
@@ -26,7 +28,8 @@
|
|||||||
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
||||||
"tokenThreshold": "Session tokens exceed {{tokens}}",
|
"tokenThreshold": "Session tokens exceed {{tokens}}",
|
||||||
"costThreshold": "Session cost reaches {{usd}}",
|
"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": {
|
"rules": {
|
||||||
"title": "Alert rules",
|
"title": "Alert rules",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"share": "Share",
|
"share": "Share",
|
||||||
"unshare": "Unshare",
|
"unshare": "Unshare",
|
||||||
"shareDone": "Shared to the Hub",
|
"shareDone": "Shared to the Hub",
|
||||||
"unshareDone": "Removed from the Hub",
|
"unshareDone": "No longer shared",
|
||||||
"reshare": "Share again",
|
"reshare": "Share again",
|
||||||
"reshareDone": "New version shared — people who installed it are offered Update",
|
"reshareDone": "New version shared — people who installed it are offered Update",
|
||||||
"reshareSame": "Already the newest version — shared",
|
"reshareSame": "Already the newest version — shared",
|
||||||
@@ -234,5 +234,50 @@
|
|||||||
"pack.updateN": "Update {{count}}",
|
"pack.updateN": "Update {{count}}",
|
||||||
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
||||||
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
||||||
"pack.updated": "Updated {{ok}} · failed {{failed}}"
|
"pack.updated": "Updated {{ok}} · failed {{failed}}",
|
||||||
|
"tab.withme": "Shared with you",
|
||||||
|
"sharing": "Sharing…",
|
||||||
|
"shareTitle": "Share “{{title}}”",
|
||||||
|
"shareMode.people": "Only the people I pick",
|
||||||
|
"shareMode.peopleHint": "Only them (and admins) see it; nobody else finds it in the Hub.",
|
||||||
|
"shareMode.everyone": "Everyone (the Hub)",
|
||||||
|
"shareMode.everyoneHint": "Every user sees it in the Hub.",
|
||||||
|
"shareMode.nobody": "Stop sharing",
|
||||||
|
"shareMode.nobodyHint": "Only you (and admins) see it again.",
|
||||||
|
"sharePeople.search": "Find a person",
|
||||||
|
"sharePeople.none": "Nobody matches.",
|
||||||
|
"sharePeople.picked_one": "{{count}} person picked",
|
||||||
|
"sharePeople.picked_other": "{{count}} people picked",
|
||||||
|
"shareSave": "Save",
|
||||||
|
"sharePeopleDone_one": "Shared with {{count}} person",
|
||||||
|
"sharePeopleDone_other": "Shared with {{count}} people",
|
||||||
|
"sharedWithCount_one": "Shared with {{count}} person",
|
||||||
|
"sharedWithCount_other": "Shared with {{count}} people",
|
||||||
|
"sharedWithMe": "Shared with you",
|
||||||
|
"withme.empty": "Nothing has been shared with you by name yet.",
|
||||||
|
"folders.all": "All folders",
|
||||||
|
"folders.new": "New folder",
|
||||||
|
"folders.newName": "Folder name",
|
||||||
|
"folders.rename": "Rename",
|
||||||
|
"folders.delete": "Delete folder",
|
||||||
|
"folders.deleteTitle": "Delete the folder “{{name}}”?",
|
||||||
|
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
|
||||||
|
"folders.empty": "This folder is empty. Put an artifact here with ⋯ → Move to folder.",
|
||||||
|
"folders.move": "Move to folder…",
|
||||||
|
"folders.moveTitle": "Move “{{title}}” to a folder",
|
||||||
|
"folders.private": "Your folders are yours alone: nobody else sees them.",
|
||||||
|
"folders.createHere": "New folder",
|
||||||
|
"folders.takeOut": "Take it out of its folder",
|
||||||
|
"page.view": "View",
|
||||||
|
"page.viewHint": "Its copy, viewable here without the claude.ai account that published it",
|
||||||
|
"page.noCopy": "No viewable copy: the file this page was published from is gone. Open it on claude.ai instead.",
|
||||||
|
"pdf.page": "Page {{n}}",
|
||||||
|
"pdf.failed": "This PDF could not be shown. Download it instead.",
|
||||||
|
"pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.",
|
||||||
|
"exportsSwitch": "Keep a viewable copy of Docs and Slides",
|
||||||
|
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards.",
|
||||||
|
"folders.moved": "Moved to “{{name}}”",
|
||||||
|
"folders.takenOut": "Taken out of its folder",
|
||||||
|
"folders.dropHint": "Drop an artifact here to put it in this folder",
|
||||||
|
"folders.dropOutHint": "Drop an artifact here to take it out of its folder"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,5 +223,9 @@
|
|||||||
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
||||||
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"search": "Search…",
|
||||||
|
"noMatch": "No match"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
"gitBar.pushHint": "git push -u origin HEAD",
|
"gitBar.pushHint": "git push -u origin HEAD",
|
||||||
"gitBar.switch": "Branch",
|
"gitBar.switch": "Branch",
|
||||||
"gitBar.switchApply": "Switch branch",
|
"gitBar.switchApply": "Switch branch",
|
||||||
|
"gitBar.branchSearch": "Search branches…",
|
||||||
"gitBar.switchCurrent": "current",
|
"gitBar.switchCurrent": "current",
|
||||||
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||||
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
|
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
|
||||||
@@ -276,6 +277,7 @@
|
|||||||
"renameLabel": "Rename this workspace",
|
"renameLabel": "Rename this workspace",
|
||||||
"proxy.native": "Native ({{tool}})",
|
"proxy.native": "Native ({{tool}})",
|
||||||
"proxy.label": "AI Proxy",
|
"proxy.label": "AI Proxy",
|
||||||
|
"proxy.company": "Company account",
|
||||||
"runtime.down": "down",
|
"runtime.down": "down",
|
||||||
"runtime.healthy": "healthy",
|
"runtime.healthy": "healthy",
|
||||||
"runtime.partial": "partial",
|
"runtime.partial": "partial",
|
||||||
@@ -748,7 +750,7 @@
|
|||||||
"shelves.deleted": "Shelf deleted",
|
"shelves.deleted": "Shelf deleted",
|
||||||
"shelves.restored": "Shelved changes restored",
|
"shelves.restored": "Shelved changes restored",
|
||||||
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
|
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
|
||||||
"shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf",
|
"shelves.restoreDirty": "Commit, discard or unstage the changes in this shelf's files (and anything staged) before restoring it",
|
||||||
"shelves.shelveTicked": "Shelve ({{count}})",
|
"shelves.shelveTicked": "Shelve ({{count}})",
|
||||||
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
|
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
|
||||||
"shelves.shelveApply": "Shelve {{count}} file(s)",
|
"shelves.shelveApply": "Shelve {{count}} file(s)",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"unmute": "Unmute",
|
"unmute": "Unmute",
|
||||||
"clearAlerts": "Clear alerts",
|
"clearAlerts": "Clear alerts",
|
||||||
"send": "Send",
|
|
||||||
"rename": "Click to rename {{name}}",
|
"rename": "Click to rename {{name}}",
|
||||||
"renameLabel": "Pet name",
|
"renameLabel": "Pet name",
|
||||||
"renameHint": "Enter to save · empty = default name",
|
"renameHint": "Enter to save · empty = default name",
|
||||||
@@ -53,13 +52,15 @@
|
|||||||
"attention": "Needs you",
|
"attention": "Needs you",
|
||||||
"attentionError": "errored",
|
"attentionError": "errored",
|
||||||
"attentionWaiting": "waiting on you",
|
"attentionWaiting": "waiting on you",
|
||||||
"attentionMore": "+{{count}} more in Sessions"
|
"attentionMore": "+{{count}} more in Sessions",
|
||||||
|
"label": "Pet {{name}}",
|
||||||
|
"close": "Close {{name}}",
|
||||||
|
"status": "Status — click to show the sessions that need you"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Dismiss",
|
"bubbleDismiss": "Dismiss",
|
||||||
"open": "Open {{name}} companion",
|
"open": "Open {{name}} companion",
|
||||||
"closeAvatar": "Close {{name}}",
|
"closeAvatar": "Close {{name}}",
|
||||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||||
"startFailed": "Couldn't start that session: {{error}}",
|
|
||||||
"bubbleOpen": "Open",
|
"bubbleOpen": "Open",
|
||||||
"bubbleMore": "{{list}} +{{count}} more",
|
"bubbleMore": "{{list}} +{{count}} more",
|
||||||
"notify": {
|
"notify": {
|
||||||
@@ -77,21 +78,13 @@
|
|||||||
"run_done_other": "{{count}} runs finished 🐾"
|
"run_done_other": "{{count}} runs finished 🐾"
|
||||||
},
|
},
|
||||||
"greet": {
|
"greet": {
|
||||||
"morning": [
|
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
|
||||||
"good morning! ☀️",
|
|
||||||
"morning! coffee first? ☕",
|
|
||||||
"rise and shine 🌤️"
|
|
||||||
],
|
|
||||||
"afternoon": [
|
"afternoon": [
|
||||||
"good afternoon! 🐾",
|
"good afternoon! 🐾",
|
||||||
"hey, welcome back 😺",
|
"hey, welcome back 😺",
|
||||||
"afternoon! ready when you are ✨"
|
"afternoon! ready when you are ✨"
|
||||||
],
|
],
|
||||||
"evening": [
|
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
|
||||||
"good evening! 🌆",
|
|
||||||
"evening shift? I'm in 😼",
|
|
||||||
"hey there, night owl-to-be 🌙"
|
|
||||||
],
|
|
||||||
"night": [
|
"night": [
|
||||||
"burning the midnight oil? 🌙",
|
"burning the midnight oil? 🌙",
|
||||||
"late one, huh? I'll keep watch 🦉",
|
"late one, huh? I'll keep watch 🦉",
|
||||||
@@ -114,11 +107,7 @@
|
|||||||
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
||||||
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
||||||
"hello": "hi there! 😺 {{summary}}.",
|
"hello": "hi there! 😺 {{summary}}.",
|
||||||
"thanks": [
|
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
|
||||||
"anytime! 💜",
|
|
||||||
"happy to help 😸",
|
|
||||||
"you got it 🐾"
|
|
||||||
],
|
|
||||||
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
||||||
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||||
"errorsNone": "no errors - all clean 🐾",
|
"errorsNone": "no errors - all clean 🐾",
|
||||||
@@ -175,125 +164,17 @@
|
|||||||
"can't reach the server 📡",
|
"can't reach the server 📡",
|
||||||
"no signal - taking a nap 💤"
|
"no signal - taking a nap 💤"
|
||||||
],
|
],
|
||||||
"worried": [
|
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||||
"that didn't look right 😟",
|
|
||||||
"keeping an eye out 👀",
|
|
||||||
"hmm, something's off 🫣"
|
|
||||||
],
|
|
||||||
"stuck": [
|
"stuck": [
|
||||||
"a session's been quiet a while… 🤔",
|
"a session's been quiet a while… 🤔",
|
||||||
"is something stuck? ⏳",
|
"is something stuck? ⏳",
|
||||||
"still chewing on it… 😾"
|
"still chewing on it… 😾"
|
||||||
],
|
],
|
||||||
"happy": [
|
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||||
"great run! 😻",
|
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||||
"love a tidy finish ✨",
|
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||||
"purrfect 💜"
|
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||||
],
|
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
||||||
"thinking": [
|
|
||||||
"hmm, let me look… 🤔",
|
|
||||||
"sniffing around… 🐾",
|
|
||||||
"one sec, checking 🔍"
|
|
||||||
],
|
|
||||||
"watching": [
|
|
||||||
"on the prowl 👀",
|
|
||||||
"watching your sessions 😼",
|
|
||||||
"eyes peeled 🐾"
|
|
||||||
],
|
|
||||||
"sleeping": [
|
|
||||||
"zzz… 💤",
|
|
||||||
"wake me if something happens 😴",
|
|
||||||
"curled up, all calm 🐈"
|
|
||||||
],
|
|
||||||
"idle": [
|
|
||||||
"all quiet 😺",
|
|
||||||
"ready when you are 🐾",
|
|
||||||
"just vibing ✨"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"label": "Chat with {{name}}",
|
|
||||||
"close": "Close {{name}}",
|
|
||||||
"new": "New chat",
|
|
||||||
"status": "Status — click to show the sessions that need you",
|
|
||||||
"endTitle": "End this chat?",
|
|
||||||
"endBody": "It is still writing a reply. A new chat stops this session.",
|
|
||||||
"endConfirm": "Stop and start new",
|
|
||||||
"openWorkspace": "Open in workspace",
|
|
||||||
"typing": "typing…",
|
|
||||||
"placeholder": "Ask {{name}} anything…",
|
|
||||||
"emptyTitle": "Hi! I'm {{name}} 🐾",
|
|
||||||
"emptyHint": "Ask me anything — I'll get {{tool}} to answer right here.",
|
|
||||||
"starting": "Starting {{tool}}…",
|
|
||||||
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
|
||||||
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
|
|
||||||
},
|
|
||||||
"people": {
|
|
||||||
"rail": "Chats",
|
|
||||||
"new": "New chat",
|
|
||||||
"newHint": "Pick one person for a direct chat, or several for a group.",
|
|
||||||
"search": "Search people",
|
|
||||||
"none": "No one found",
|
|
||||||
"groupName": "Group name (optional)",
|
|
||||||
"createGroup": "Create group ({{n}} people)",
|
|
||||||
"startDm": "Start chat",
|
|
||||||
"close": "Close",
|
|
||||||
"unreadIn": "{{name}}, {{n}} unread",
|
|
||||||
"roomLabel": "Chat with {{name}}",
|
|
||||||
"members": "{{n}} members",
|
|
||||||
"settings": "Group settings",
|
|
||||||
"back": "Back to the chat",
|
|
||||||
"rename": "Rename",
|
|
||||||
"creator": "· created the group",
|
|
||||||
"remove": "Remove {{name}}",
|
|
||||||
"add": "Add people",
|
|
||||||
"addN": "Add {{n}}",
|
|
||||||
"leave": "Leave group",
|
|
||||||
"leaveTitle": "Leave this group?",
|
|
||||||
"leaveBody": "You will no longer see {{name}}. Someone in it can add you again.",
|
|
||||||
"older": "Load older messages",
|
|
||||||
"empty": "No messages yet. Say hello to {{name}}.",
|
|
||||||
"placeholder": "Message {{name}}",
|
|
||||||
"attach": "Attach files",
|
|
||||||
"unattach": "Remove {{name}}",
|
|
||||||
"tooBig": "{{name}} is over 25 MB",
|
|
||||||
"sysCreated": "{{who}} created the group",
|
|
||||||
"sysRenamed": "{{who}} renamed the group",
|
|
||||||
"sysLeft": "{{who}} left",
|
|
||||||
"sysAdded": "{{who}} added {{names}}",
|
|
||||||
"sysRemoved": "{{who}} removed {{names}}",
|
|
||||||
"openFrom": "Open the chat with {{name}}",
|
|
||||||
"menuOpen": "Open",
|
|
||||||
"menuRead": "Mark as read",
|
|
||||||
"menuReadAll": "Mark all chats as read",
|
|
||||||
"menuMute": "Mute notifications",
|
|
||||||
"menuUnmute": "Unmute notifications",
|
|
||||||
"menuCopyName": "Copy name",
|
|
||||||
"menuPet": "Chat with {{name}}",
|
|
||||||
"menuDm": "Message {{name}} privately",
|
|
||||||
"menuOpenFile": "Open in a new tab",
|
|
||||||
"menuDownload": "Download",
|
|
||||||
"menuCopyFileName": "Copy file name",
|
|
||||||
"menuCopyText": "Copy text",
|
|
||||||
"menuReply": "Reply",
|
|
||||||
"menuDelete": "Delete message",
|
|
||||||
"deleteTitle": "Delete this message?",
|
|
||||||
"deleteBody": "It is removed for everyone in the chat, with its files. This cannot be undone.",
|
|
||||||
"menuClearDm": "Delete chat",
|
|
||||||
"menuClearGroup": "Delete chat history",
|
|
||||||
"clearDmTitle": "Delete this chat?",
|
|
||||||
"clearDmBody": "The chat with {{name}} and its history are deleted for you only — they keep theirs. It comes back, without the old messages, if either of you writes again.",
|
|
||||||
"clearGroupTitle": "Delete this group's history?",
|
|
||||||
"clearGroupBody": "Every message in {{name}} so far is deleted for you only. You stay in the group; the others keep theirs.",
|
|
||||||
"disband": "Disband group",
|
|
||||||
"disbandTitle": "Disband this group?",
|
|
||||||
"disbandBody": "{{name}} and all its messages and files are deleted for every member. This cannot be undone.",
|
|
||||||
"seen": "Seen",
|
|
||||||
"seenAll": "Seen by everyone",
|
|
||||||
"seenBy": "Seen by {{names}}",
|
|
||||||
"deletedUser": "Deleted user",
|
|
||||||
"dock": "Chats",
|
|
||||||
"dockUnread": "Chats, {{n}} unread"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
@@ -1272,6 +1153,11 @@
|
|||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"memberPolicy": {
|
||||||
|
"title": "Tests on this server",
|
||||||
|
"switch": "Allow members to run tests",
|
||||||
|
"hint": "Off: members' Claude may not run test runners or container commands here (vitest, jest, phpunit, pytest, npm test, docker, …) — a few at once freeze this shared server, so they test on their own machine. On: they may. Commands typed by hand in a terminal and Codex are not checked either way."
|
||||||
|
},
|
||||||
"ompSharing": {
|
"ompSharing": {
|
||||||
"title": "Oh My Pi login sharing",
|
"title": "Oh My Pi login sharing",
|
||||||
"switch": "Share this server's Oh My Pi login",
|
"switch": "Share this server's Oh My Pi login",
|
||||||
@@ -1564,5 +1450,49 @@
|
|||||||
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||||
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||||
"nextSession": "{{account}} is used from your next session"
|
"nextSession": "{{account}} is used from your next session"
|
||||||
|
},
|
||||||
|
"companyHub": {
|
||||||
|
"title": "Company hub",
|
||||||
|
"description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.",
|
||||||
|
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first.",
|
||||||
|
"ruleNotSent": "Sessions on your own login or your own AI Proxy send nothing — not even the folder name. The hub never reads your files and never controls this machine.",
|
||||||
|
"ruleKey": "The company account's credential stays on the hub; this machine keeps only a device token (90 days, revocable).",
|
||||||
|
"ruleOffice": "The hub is on the company network: away from it, Company-account sessions cannot start; your own login works as usual.",
|
||||||
|
"enrolledAs": "Enrolled with {{hub}} as {{user}}",
|
||||||
|
"validUntil": "valid until {{time}}",
|
||||||
|
"expired": "expired",
|
||||||
|
"expiredHint": "This machine's hub login expired — enroll again.",
|
||||||
|
"sendNow": "Send now",
|
||||||
|
"logout": "Log out",
|
||||||
|
"logoutTitle": "Log this machine out of the hub?",
|
||||||
|
"logoutHint": "Its device token is revoked on the hub. Company-account sessions stop working here until you enroll again.",
|
||||||
|
"sentTitle": "Sent to the hub",
|
||||||
|
"sentLine": "{{bytes}} · {{time}}",
|
||||||
|
"notSentYet": "not sent yet",
|
||||||
|
"ended": "ended",
|
||||||
|
"nothingSent": "No Company-account session yet.",
|
||||||
|
"sentToast": "Sent {{bytes}} of {{sessions}} session(s)",
|
||||||
|
"enrolledToast": "This machine is enrolled with the hub",
|
||||||
|
"hubAddress": "Hub address",
|
||||||
|
"username": "Hub username",
|
||||||
|
"password": "Hub password",
|
||||||
|
"enroll": "Enroll this machine",
|
||||||
|
"cliHint": "Or in a terminal: agenthub remote login <hub address>",
|
||||||
|
"hubTitle": "Your machines",
|
||||||
|
"hubDescription": "Machines running AgentHub local that you enrolled with this hub, and their Company-account sessions.",
|
||||||
|
"noDevices": "No machine enrolled.",
|
||||||
|
"revokedAt": "revoked {{time}}",
|
||||||
|
"lastSeen": "seen {{time}} from {{ip}}",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revokeTitle": "Revoke {{name}}?",
|
||||||
|
"revokeHint": "Its device token stops working at once; the machine must enroll again.",
|
||||||
|
"revoked": "{{name}} revoked",
|
||||||
|
"yourPassword": "Your password",
|
||||||
|
"sessions": "Company-account sessions",
|
||||||
|
"unmonitored": "not started from AgentHub",
|
||||||
|
"sessionLine": "{{requests}} requests · {{input}} in · {{output}} out · {{account}}",
|
||||||
|
"purged": "conversation removed (retention)",
|
||||||
|
"whoRead": "Who opened your sessions",
|
||||||
|
"nobodyRead": "Nobody has opened your sessions."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,7 +409,8 @@
|
|||||||
"activeLabel": "Running now",
|
"activeLabel": "Running now",
|
||||||
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
||||||
"nowTitle": "Running now",
|
"nowTitle": "Running now",
|
||||||
"poolTheirs7d": "theirs 7 d {{cost}}"
|
"poolTheirs7d": "theirs 7 d {{cost}}",
|
||||||
|
"devicesTitle": "Machines (AgentHub local)"
|
||||||
},
|
},
|
||||||
"activity": "Activity",
|
"activity": "Activity",
|
||||||
"spend": "Spend today",
|
"spend": "Spend today",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"status_duration": "Agent bị kẹt",
|
"status_duration": "Agent bị kẹt",
|
||||||
"token_threshold": "Ngưỡng token",
|
"token_threshold": "Ngưỡng token",
|
||||||
"cost_threshold": "Ngưỡng chi phí",
|
"cost_threshold": "Ngưỡng chi phí",
|
||||||
"budget": "Ngân sách chi tiêu"
|
"budget": "Ngân sách chi tiêu",
|
||||||
|
"remote_unmonitored": "Tài khoản công ty dùng ngoài AgentHub"
|
||||||
},
|
},
|
||||||
"ruleTypeHints": {
|
"ruleTypeHints": {
|
||||||
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
|
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
|
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
|
||||||
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
|
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
|
||||||
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
|
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
|
||||||
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI."
|
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI.",
|
||||||
|
"remote_unmonitored": "Kích hoạt khi máy của một người dùng tài khoản công ty cho một phiên không mở từ AgentHub (phiên đó không được gửi về hub). Chỉ có ở chế độ server."
|
||||||
},
|
},
|
||||||
"ruleDesc": {
|
"ruleDesc": {
|
||||||
"eventPattern": "Khớp {{pattern}}",
|
"eventPattern": "Khớp {{pattern}}",
|
||||||
@@ -26,7 +28,8 @@
|
|||||||
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
|
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
|
||||||
"tokenThreshold": "Token của phiên vượt {{tokens}}",
|
"tokenThreshold": "Token của phiên vượt {{tokens}}",
|
||||||
"costThreshold": "Chi phí phiên chạm {{usd}}",
|
"costThreshold": "Chi phí phiên chạm {{usd}}",
|
||||||
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}"
|
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}",
|
||||||
|
"remoteUnmonitored": "phiên không mở từ AgentHub"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Quy tắc cảnh báo",
|
"title": "Quy tắc cảnh báo",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"share": "Chia sẻ",
|
"share": "Chia sẻ",
|
||||||
"unshare": "Bỏ chia sẻ",
|
"unshare": "Bỏ chia sẻ",
|
||||||
"shareDone": "Đã chia sẻ lên Hub",
|
"shareDone": "Đã chia sẻ lên Hub",
|
||||||
"unshareDone": "Đã gỡ khỏi Hub",
|
"unshareDone": "Đã ngừng chia sẻ",
|
||||||
"reshare": "Share lại",
|
"reshare": "Share lại",
|
||||||
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
||||||
"reshareSame": "Đã là bản mới nhất — đã share",
|
"reshareSame": "Đã là bản mới nhất — đã share",
|
||||||
@@ -232,5 +232,50 @@
|
|||||||
"pack.updateN": "Cập nhật {{count}}",
|
"pack.updateN": "Cập nhật {{count}}",
|
||||||
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
||||||
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
||||||
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
|
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}",
|
||||||
|
"tab.withme": "Chia sẻ với bạn",
|
||||||
|
"sharing": "Chia sẻ…",
|
||||||
|
"shareTitle": "Chia sẻ “{{title}}”",
|
||||||
|
"shareMode.people": "Chỉ những người tôi chọn",
|
||||||
|
"shareMode.peopleHint": "Chỉ họ (và admin) thấy; người khác không thấy trên Hub.",
|
||||||
|
"shareMode.everyone": "Mọi người (Hub)",
|
||||||
|
"shareMode.everyoneHint": "Mọi người dùng đều thấy trên Hub.",
|
||||||
|
"shareMode.nobody": "Ngừng chia sẻ",
|
||||||
|
"shareMode.nobodyHint": "Chỉ bạn (và admin) thấy như trước.",
|
||||||
|
"sharePeople.search": "Tìm người",
|
||||||
|
"sharePeople.none": "Không có ai khớp.",
|
||||||
|
"sharePeople.picked_one": "Đã chọn {{count}} người",
|
||||||
|
"sharePeople.picked_other": "Đã chọn {{count}} người",
|
||||||
|
"shareSave": "Lưu",
|
||||||
|
"sharePeopleDone_one": "Đã chia sẻ với {{count}} người",
|
||||||
|
"sharePeopleDone_other": "Đã chia sẻ với {{count}} người",
|
||||||
|
"sharedWithCount_one": "Chia sẻ với {{count}} người",
|
||||||
|
"sharedWithCount_other": "Chia sẻ với {{count}} người",
|
||||||
|
"sharedWithMe": "Chia sẻ với bạn",
|
||||||
|
"withme.empty": "Chưa có ai chia sẻ riêng với bạn.",
|
||||||
|
"folders.all": "Tất cả thư mục",
|
||||||
|
"folders.new": "Thư mục mới",
|
||||||
|
"folders.newName": "Tên thư mục",
|
||||||
|
"folders.rename": "Đổi tên",
|
||||||
|
"folders.delete": "Xóa thư mục",
|
||||||
|
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
|
||||||
|
"folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.",
|
||||||
|
"folders.empty": "Thư mục này trống. Đưa artifact vào bằng ⋯ → Chuyển vào thư mục.",
|
||||||
|
"folders.move": "Chuyển vào thư mục…",
|
||||||
|
"folders.moveTitle": "Chuyển “{{title}}” vào thư mục",
|
||||||
|
"folders.private": "Thư mục là của riêng bạn: người khác không thấy.",
|
||||||
|
"folders.createHere": "Thư mục mới",
|
||||||
|
"folders.takeOut": "Bỏ khỏi thư mục",
|
||||||
|
"page.view": "Xem",
|
||||||
|
"page.viewHint": "Bản sao xem được ngay tại đây, không cần tài khoản claude.ai đã tạo trang",
|
||||||
|
"page.noCopy": "Chưa có bản xem: file gốc của trang này không còn. Hãy mở trên claude.ai.",
|
||||||
|
"pdf.page": "Trang {{n}}",
|
||||||
|
"pdf.failed": "Không hiển thị được PDF này. Hãy tải về.",
|
||||||
|
"pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.",
|
||||||
|
"exportsSwitch": "Giữ bản xem được của Docs và Slides",
|
||||||
|
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó.",
|
||||||
|
"folders.moved": "Đã chuyển vào “{{name}}”",
|
||||||
|
"folders.takenOut": "Đã bỏ khỏi thư mục",
|
||||||
|
"folders.dropHint": "Thả artifact vào đây để đưa vào thư mục này",
|
||||||
|
"folders.dropOutHint": "Thả artifact vào đây để bỏ khỏi thư mục"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,5 +223,9 @@
|
|||||||
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
|
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
|
||||||
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
|
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"search": "Tìm…",
|
||||||
|
"noMatch": "Không có kết quả"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
"gitBar.pushHint": "git push -u origin HEAD",
|
"gitBar.pushHint": "git push -u origin HEAD",
|
||||||
"gitBar.switch": "Nhánh",
|
"gitBar.switch": "Nhánh",
|
||||||
"gitBar.switchApply": "Chuyển nhánh",
|
"gitBar.switchApply": "Chuyển nhánh",
|
||||||
|
"gitBar.branchSearch": "Tìm nhánh…",
|
||||||
"gitBar.switchCurrent": "hiện tại",
|
"gitBar.switchCurrent": "hiện tại",
|
||||||
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
||||||
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
|
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
|
||||||
@@ -276,6 +277,7 @@
|
|||||||
"renameLabel": "Đổi tên workspace này",
|
"renameLabel": "Đổi tên workspace này",
|
||||||
"proxy.native": "Native ({{tool}})",
|
"proxy.native": "Native ({{tool}})",
|
||||||
"proxy.label": "AI Proxy",
|
"proxy.label": "AI Proxy",
|
||||||
|
"proxy.company": "Tài khoản công ty",
|
||||||
"runtime.down": "tắt",
|
"runtime.down": "tắt",
|
||||||
"runtime.healthy": "khoẻ",
|
"runtime.healthy": "khoẻ",
|
||||||
"runtime.partial": "một phần",
|
"runtime.partial": "một phần",
|
||||||
@@ -748,7 +750,7 @@
|
|||||||
"shelves.deleted": "Đã xoá shelf",
|
"shelves.deleted": "Đã xoá shelf",
|
||||||
"shelves.restored": "Đã khôi phục thay đổi đã cất",
|
"shelves.restored": "Đã khôi phục thay đổi đã cất",
|
||||||
"shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.",
|
"shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.",
|
||||||
"shelves.restoreDirty": "Hãy commit, bỏ hoặc cất thay đổi hiện tại trước khi khôi phục một shelf",
|
"shelves.restoreDirty": "Hãy commit, bỏ hoặc bỏ stage thay đổi trên các file của shelf này (và mọi thứ đã stage) trước khi khôi phục",
|
||||||
"shelves.shelveTicked": "Cất ({{count}})",
|
"shelves.shelveTicked": "Cất ({{count}})",
|
||||||
"shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc",
|
"shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc",
|
||||||
"shelves.shelveApply": "Cất {{count}} file",
|
"shelves.shelveApply": "Cất {{count}} file",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"mute": "Tắt tiếng",
|
"mute": "Tắt tiếng",
|
||||||
"unmute": "Bật tiếng",
|
"unmute": "Bật tiếng",
|
||||||
"clearAlerts": "Xoá cảnh báo",
|
"clearAlerts": "Xoá cảnh báo",
|
||||||
"send": "Gửi",
|
|
||||||
"rename": "Bấm để đổi tên {{name}}",
|
"rename": "Bấm để đổi tên {{name}}",
|
||||||
"renameLabel": "Tên pet",
|
"renameLabel": "Tên pet",
|
||||||
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
||||||
@@ -53,13 +52,15 @@
|
|||||||
"attention": "Cần bạn xem",
|
"attention": "Cần bạn xem",
|
||||||
"attentionError": "bị lỗi",
|
"attentionError": "bị lỗi",
|
||||||
"attentionWaiting": "đang chờ bạn",
|
"attentionWaiting": "đang chờ bạn",
|
||||||
"attentionMore": "+{{count}} phiên khác trong Phiên"
|
"attentionMore": "+{{count}} phiên khác trong Phiên",
|
||||||
|
"label": "Pet {{name}}",
|
||||||
|
"close": "Đóng {{name}}",
|
||||||
|
"status": "Trạng thái — bấm để xem các phiên cần bạn"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Đóng",
|
"bubbleDismiss": "Đóng",
|
||||||
"open": "Mở trợ lý {{name}}",
|
"open": "Mở trợ lý {{name}}",
|
||||||
"closeAvatar": "Đóng {{name}}",
|
"closeAvatar": "Đóng {{name}}",
|
||||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
||||||
"startFailed": "Không khởi động được phiên: {{error}}",
|
|
||||||
"bubbleOpen": "Mở",
|
"bubbleOpen": "Mở",
|
||||||
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
||||||
"notify": {
|
"notify": {
|
||||||
@@ -77,26 +78,10 @@
|
|||||||
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||||
},
|
},
|
||||||
"greet": {
|
"greet": {
|
||||||
"morning": [
|
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
|
||||||
"chào buổi sáng! ☀️",
|
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
|
||||||
"sáng rồi! cà phê chưa? ☕",
|
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
|
||||||
"ngày mới tốt lành nha 🌤️"
|
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
|
||||||
],
|
|
||||||
"afternoon": [
|
|
||||||
"chào buổi chiều! 🐾",
|
|
||||||
"bạn quay lại rồi 😺",
|
|
||||||
"chiều nay làm gì đây? ✨"
|
|
||||||
],
|
|
||||||
"evening": [
|
|
||||||
"chào buổi tối! 🌆",
|
|
||||||
"ca tối hả? có mình đây 😼",
|
|
||||||
"tối rồi, cố lên nhé 🌙"
|
|
||||||
],
|
|
||||||
"night": [
|
|
||||||
"khuya rồi mà vẫn làm à? 🌙",
|
|
||||||
"để mình canh giúp cho 🦉",
|
|
||||||
"nhớ nghỉ ngơi nha 💤"
|
|
||||||
],
|
|
||||||
"summary_one": "{{count}} phiên đang chạy",
|
"summary_one": "{{count}} phiên đang chạy",
|
||||||
"summary_other": "{{count}} phiên đang chạy",
|
"summary_other": "{{count}} phiên đang chạy",
|
||||||
"summaryWaiting": "{{count}} phiên chờ bạn",
|
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||||
@@ -114,11 +99,7 @@
|
|||||||
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
||||||
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
||||||
"hello": "chào bạn! 😺 {{summary}}.",
|
"hello": "chào bạn! 😺 {{summary}}.",
|
||||||
"thanks": [
|
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
|
||||||
"có gì đâu! 💜",
|
|
||||||
"rất vui được giúp 😸",
|
|
||||||
"chuyện nhỏ 🐾"
|
|
||||||
],
|
|
||||||
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||||
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||||
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||||
@@ -175,125 +156,13 @@
|
|||||||
"không gọi được máy chủ 📡",
|
"không gọi được máy chủ 📡",
|
||||||
"mất sóng - đi ngủ chút 💤"
|
"mất sóng - đi ngủ chút 💤"
|
||||||
],
|
],
|
||||||
"worried": [
|
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
|
||||||
"có vẻ không ổn 😟",
|
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
|
||||||
"mình đang để mắt đây 👀",
|
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
|
||||||
"hừm, có gì đó lạ lạ 🫣"
|
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
|
||||||
],
|
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
||||||
"stuck": [
|
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
|
||||||
"một phiên im lặng lâu rồi… 🤔",
|
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
||||||
"có gì bị kẹt không? ⏳",
|
|
||||||
"vẫn đang nhai… 😾"
|
|
||||||
],
|
|
||||||
"happy": [
|
|
||||||
"tuyệt vời! 😻",
|
|
||||||
"kết thúc gọn ghê ✨",
|
|
||||||
"hoàn hảo 💜"
|
|
||||||
],
|
|
||||||
"thinking": [
|
|
||||||
"để mình xem… 🤔",
|
|
||||||
"đang đánh hơi… 🐾",
|
|
||||||
"chờ chút, đang kiểm tra 🔍"
|
|
||||||
],
|
|
||||||
"watching": [
|
|
||||||
"đang tuần tra 👀",
|
|
||||||
"đang canh các phiên của bạn 😼",
|
|
||||||
"mắt mở to 🐾"
|
|
||||||
],
|
|
||||||
"sleeping": [
|
|
||||||
"zzz… 💤",
|
|
||||||
"có gì thì gọi mình dậy nhé 😴",
|
|
||||||
"cuộn tròn, mọi thứ yên ổn 🐈"
|
|
||||||
],
|
|
||||||
"idle": [
|
|
||||||
"yên ắng quá 😺",
|
|
||||||
"sẵn sàng khi bạn cần 🐾",
|
|
||||||
"chill thôi ✨"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"label": "Trò chuyện với {{name}}",
|
|
||||||
"close": "Đóng {{name}}",
|
|
||||||
"new": "Cuộc trò chuyện mới",
|
|
||||||
"status": "Trạng thái — bấm để xem các phiên cần bạn",
|
|
||||||
"endTitle": "Kết thúc chat này?",
|
|
||||||
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
|
|
||||||
"endConfirm": "Dừng và bắt đầu mới",
|
|
||||||
"openWorkspace": "Mở trong workspace",
|
|
||||||
"typing": "đang trả lời…",
|
|
||||||
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
|
||||||
"emptyTitle": "Chào bạn! Mình là {{name}} 🐾",
|
|
||||||
"emptyHint": "Hỏi gì cũng được — mình sẽ nhờ {{tool}} trả lời ngay tại đây.",
|
|
||||||
"starting": "Đang khởi động {{tool}}…",
|
|
||||||
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
|
|
||||||
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
|
|
||||||
},
|
|
||||||
"people": {
|
|
||||||
"rail": "Trò chuyện",
|
|
||||||
"new": "Chat mới",
|
|
||||||
"newHint": "Chọn 1 người để chat riêng, hoặc nhiều người để tạo nhóm.",
|
|
||||||
"search": "Tìm người",
|
|
||||||
"none": "Không tìm thấy ai",
|
|
||||||
"groupName": "Tên nhóm (không bắt buộc)",
|
|
||||||
"createGroup": "Tạo nhóm ({{n}} người)",
|
|
||||||
"startDm": "Bắt đầu chat",
|
|
||||||
"close": "Đóng",
|
|
||||||
"unreadIn": "{{name}}, {{n}} tin chưa đọc",
|
|
||||||
"roomLabel": "Chat với {{name}}",
|
|
||||||
"members": "{{n}} thành viên",
|
|
||||||
"settings": "Cài đặt nhóm",
|
|
||||||
"back": "Quay lại cuộc chat",
|
|
||||||
"rename": "Đổi tên",
|
|
||||||
"creator": "· người tạo nhóm",
|
|
||||||
"remove": "Xoá {{name}} khỏi nhóm",
|
|
||||||
"add": "Thêm người",
|
|
||||||
"addN": "Thêm {{n}} người",
|
|
||||||
"leave": "Rời nhóm",
|
|
||||||
"leaveTitle": "Rời nhóm này?",
|
|
||||||
"leaveBody": "Bạn sẽ không còn thấy {{name}}. Người trong nhóm có thể thêm bạn lại.",
|
|
||||||
"older": "Xem tin cũ hơn",
|
|
||||||
"empty": "Chưa có tin nhắn. Hãy chào {{name}}.",
|
|
||||||
"placeholder": "Nhắn cho {{name}}",
|
|
||||||
"attach": "Đính kèm file",
|
|
||||||
"unattach": "Bỏ {{name}}",
|
|
||||||
"tooBig": "{{name}} lớn hơn 25 MB",
|
|
||||||
"sysCreated": "{{who}} đã tạo nhóm",
|
|
||||||
"sysRenamed": "{{who}} đã đổi tên nhóm",
|
|
||||||
"sysLeft": "{{who}} đã rời nhóm",
|
|
||||||
"sysAdded": "{{who}} đã thêm {{names}}",
|
|
||||||
"sysRemoved": "{{who}} đã xoá {{names}}",
|
|
||||||
"openFrom": "Mở chat với {{name}}",
|
|
||||||
"menuOpen": "Mở",
|
|
||||||
"menuRead": "Đánh dấu đã đọc",
|
|
||||||
"menuReadAll": "Đánh dấu đã đọc tất cả",
|
|
||||||
"menuMute": "Tắt thông báo",
|
|
||||||
"menuUnmute": "Bật lại thông báo",
|
|
||||||
"menuCopyName": "Sao chép tên",
|
|
||||||
"menuPet": "Chat với {{name}}",
|
|
||||||
"menuDm": "Nhắn riêng cho {{name}}",
|
|
||||||
"menuOpenFile": "Mở trong tab mới",
|
|
||||||
"menuDownload": "Tải về",
|
|
||||||
"menuCopyFileName": "Sao chép tên file",
|
|
||||||
"menuCopyText": "Sao chép nội dung",
|
|
||||||
"menuReply": "Trả lời",
|
|
||||||
"menuDelete": "Xoá tin nhắn",
|
|
||||||
"deleteTitle": "Xoá tin nhắn này?",
|
|
||||||
"deleteBody": "Tin nhắn và file đính kèm sẽ bị xoá với mọi người trong cuộc chat. Không thể hoàn tác.",
|
|
||||||
"menuClearDm": "Xoá cuộc trò chuyện",
|
|
||||||
"menuClearGroup": "Xoá lịch sử chat",
|
|
||||||
"clearDmTitle": "Xoá cuộc trò chuyện này?",
|
|
||||||
"clearDmBody": "Cuộc trò chuyện với {{name}} và lịch sử của nó chỉ bị xoá ở phía bạn — người kia vẫn giữ. Nếu một trong hai nhắn tiếp, cuộc chat sẽ hiện lại nhưng không còn tin cũ.",
|
|
||||||
"clearGroupTitle": "Xoá lịch sử của nhóm này?",
|
|
||||||
"clearGroupBody": "Mọi tin nhắn trong {{name}} đến giờ chỉ bị xoá ở phía bạn. Bạn vẫn ở trong nhóm; người khác vẫn giữ tin của họ.",
|
|
||||||
"disband": "Giải tán nhóm",
|
|
||||||
"disbandTitle": "Giải tán nhóm này?",
|
|
||||||
"disbandBody": "{{name}} cùng toàn bộ tin nhắn và file sẽ bị xoá với mọi thành viên. Không thể hoàn tác.",
|
|
||||||
"seen": "Đã xem",
|
|
||||||
"seenAll": "Mọi người đã xem",
|
|
||||||
"seenBy": "{{names}} đã xem",
|
|
||||||
"deletedUser": "Người dùng đã xoá",
|
|
||||||
"dock": "Trò chuyện",
|
|
||||||
"dockUnread": "Trò chuyện, {{n}} tin chưa đọc"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
@@ -1272,6 +1141,11 @@
|
|||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"memberPolicy": {
|
||||||
|
"title": "Chạy test trên server này",
|
||||||
|
"switch": "Cho phép thành viên chạy test",
|
||||||
|
"hint": "Tắt: Claude của thành viên không được chạy test hay lệnh container ở đây (vitest, jest, phpunit, pytest, npm test, docker, …) — vài lượt chạy cùng lúc là server dùng chung bị đứng, nên họ test trên máy của mình. Bật: được phép chạy. Lệnh tự gõ trong terminal và Codex thì không bị kiểm tra trong cả hai trường hợp."
|
||||||
|
},
|
||||||
"ompSharing": {
|
"ompSharing": {
|
||||||
"title": "Dùng chung đăng nhập Oh My Pi",
|
"title": "Dùng chung đăng nhập Oh My Pi",
|
||||||
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
||||||
@@ -1564,5 +1438,49 @@
|
|||||||
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
||||||
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
||||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
||||||
|
},
|
||||||
|
"companyHub": {
|
||||||
|
"title": "Hub công ty",
|
||||||
|
"description": "Dùng tài khoản Claude chung của công ty từ máy này. Chỉ phần tốn tiền công ty mới được ghi nhận.",
|
||||||
|
"ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi.",
|
||||||
|
"ruleNotSent": "Session dùng tài khoản riêng hoặc AI Proxy riêng của bạn không gửi gì cả — kể cả tên thư mục. Hub không đọc file của bạn và không điều khiển máy này.",
|
||||||
|
"ruleKey": "Key của tài khoản công ty nằm ở hub; máy này chỉ giữ một device token (90 ngày, thu hồi được).",
|
||||||
|
"ruleOffice": "Hub nằm trong mạng công ty: ở ngoài, session tài khoản công ty không mở được; tài khoản riêng vẫn dùng bình thường.",
|
||||||
|
"enrolledAs": "Đã đăng ký với {{hub}} bằng {{user}}",
|
||||||
|
"validUntil": "hiệu lực đến {{time}}",
|
||||||
|
"expired": "đã hết hạn",
|
||||||
|
"expiredHint": "Đăng nhập hub của máy này đã hết hạn — đăng ký lại.",
|
||||||
|
"sendNow": "Gửi ngay",
|
||||||
|
"logout": "Đăng xuất",
|
||||||
|
"logoutTitle": "Đăng xuất máy này khỏi hub?",
|
||||||
|
"logoutHint": "Device token bị thu hồi trên hub. Session tài khoản công ty không dùng được ở máy này cho đến khi đăng ký lại.",
|
||||||
|
"sentTitle": "Đã gửi lên hub",
|
||||||
|
"sentLine": "{{bytes}} · {{time}}",
|
||||||
|
"notSentYet": "chưa gửi",
|
||||||
|
"ended": "đã kết thúc",
|
||||||
|
"nothingSent": "Chưa có session tài khoản công ty nào.",
|
||||||
|
"sentToast": "Đã gửi {{bytes}} của {{sessions}} session",
|
||||||
|
"enrolledToast": "Máy này đã đăng ký với hub",
|
||||||
|
"hubAddress": "Địa chỉ hub",
|
||||||
|
"username": "Tên đăng nhập hub",
|
||||||
|
"password": "Mật khẩu hub",
|
||||||
|
"enroll": "Đăng ký máy này",
|
||||||
|
"cliHint": "Hoặc trong terminal: agenthub remote login <địa chỉ hub>",
|
||||||
|
"hubTitle": "Máy của bạn",
|
||||||
|
"hubDescription": "Các máy chạy AgentHub local bạn đã đăng ký với hub này, và các session tài khoản công ty của chúng.",
|
||||||
|
"noDevices": "Chưa có máy nào đăng ký.",
|
||||||
|
"revokedAt": "đã thu hồi {{time}}",
|
||||||
|
"lastSeen": "thấy lần cuối {{time}} từ {{ip}}",
|
||||||
|
"revoke": "Thu hồi",
|
||||||
|
"revokeTitle": "Thu hồi {{name}}?",
|
||||||
|
"revokeHint": "Device token ngừng hoạt động ngay; máy đó phải đăng ký lại.",
|
||||||
|
"revoked": "Đã thu hồi {{name}}",
|
||||||
|
"yourPassword": "Mật khẩu của bạn",
|
||||||
|
"sessions": "Session tài khoản công ty",
|
||||||
|
"unmonitored": "không mở từ AgentHub",
|
||||||
|
"sessionLine": "{{requests}} request · {{input}} vào · {{output}} ra · {{account}}",
|
||||||
|
"purged": "đã xoá hội thoại (quá hạn lưu)",
|
||||||
|
"whoRead": "Ai đã xem session của bạn",
|
||||||
|
"nobodyRead": "Chưa ai xem session của bạn."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,7 +409,8 @@
|
|||||||
"activeLabel": "Đang chạy",
|
"activeLabel": "Đang chạy",
|
||||||
"activeValue": "{{sessions}} session · {{panes}} pane",
|
"activeValue": "{{sessions}} session · {{panes}} pane",
|
||||||
"nowTitle": "Đang chạy",
|
"nowTitle": "Đang chạy",
|
||||||
"poolTheirs7d": "của họ 7 ngày {{cost}}"
|
"poolTheirs7d": "của họ 7 ngày {{cost}}",
|
||||||
|
"devicesTitle": "Máy cá nhân (AgentHub local)"
|
||||||
},
|
},
|
||||||
"activity": "Hoạt động",
|
"activity": "Hoạt động",
|
||||||
"spend": "Chi phí hôm nay",
|
"spend": "Chi phí hôm nay",
|
||||||
|
|||||||
+147
-96
@@ -547,44 +547,6 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
|
|||||||
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
||||||
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
|
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
|
||||||
|
|
||||||
/** Someone to chat with (`GET /api/chat/people`). */
|
|
||||||
export type ChatPerson = {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
displayName: string | null;
|
|
||||||
avatarAt: string | null;
|
|
||||||
/** A disabled account: named in old chats, never offered for a new one. */
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
|
|
||||||
export type ChatFile = { id: string; name: string; type: string; size: number };
|
|
||||||
/** One chat message; `system` bodies: created, renamed, left, added:<ids>, removed:<ids>. */
|
|
||||||
export type ChatMessage = {
|
|
||||||
id: number;
|
|
||||||
roomId: number;
|
|
||||||
userId: number | null;
|
|
||||||
kind: "text" | "system";
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
files: ChatFile[];
|
|
||||||
};
|
|
||||||
/** A direct chat or a group, as the caller sees it. */
|
|
||||||
export type ChatRoom = {
|
|
||||||
id: number;
|
|
||||||
kind: "dm" | "group";
|
|
||||||
name: string;
|
|
||||||
createdBy: number | null;
|
|
||||||
createdAt: string;
|
|
||||||
lastMessageAt: string | null;
|
|
||||||
members: number[];
|
|
||||||
lastMessage: ChatMessage | null;
|
|
||||||
lastReadId: number;
|
|
||||||
/** Messages up to this id were deleted by the viewer for themselves. */
|
|
||||||
clearedId?: number;
|
|
||||||
/** Every member's read mark by user id ("Seen"); absent from an older server. */
|
|
||||||
reads?: Record<string, number>;
|
|
||||||
unread: number;
|
|
||||||
};
|
|
||||||
/** One account in Settings → Users (`GET /api/users`). */
|
/** One account in Settings → Users (`GET /api/users`). */
|
||||||
export type ServerUserUsage = AuthUser & {
|
export type ServerUserUsage = AuthUser & {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -1605,63 +1567,6 @@ export const api = {
|
|||||||
request<HostHistory>(`/host/history?range=${range}`),
|
request<HostHistory>(`/host/history?range=${range}`),
|
||||||
},
|
},
|
||||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||||
/** `/api/chat` — chat between users in server mode (the pet's window). */
|
|
||||||
chat: {
|
|
||||||
people: () => request<{ people: ChatPerson[] }>("/chat/people"),
|
|
||||||
rooms: () => request<{ rooms: ChatRoom[] }>("/chat/rooms"),
|
|
||||||
/** The one direct chat with `userId`, made on first use. */
|
|
||||||
openDm: (userId: number) =>
|
|
||||||
request<{ room: ChatRoom }>("/chat/rooms", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ kind: "dm", userId }),
|
|
||||||
}),
|
|
||||||
createGroup: (name: string, userIds: number[]) =>
|
|
||||||
request<{ room: ChatRoom }>("/chat/rooms", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ kind: "group", name, userIds }),
|
|
||||||
}),
|
|
||||||
/** The group's creator only. */
|
|
||||||
updateGroup: (id: number, patch: { name?: string; add?: number[]; remove?: number[] }) =>
|
|
||||||
request<{ room: ChatRoom }>(`/chat/rooms/${id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(patch),
|
|
||||||
}),
|
|
||||||
leave: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}/leave`, { method: "POST" }),
|
|
||||||
messages: (id: number, before?: number) =>
|
|
||||||
request<{ messages: ChatMessage[]; hasMore: boolean }>(
|
|
||||||
`/chat/rooms/${id}/messages${before ? `?before=${before}` : ""}`
|
|
||||||
),
|
|
||||||
/** Text and files (multipart when there are files). */
|
|
||||||
post: (id: number, body: string, files: File[] = []) => {
|
|
||||||
if (!files.length)
|
|
||||||
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ body }),
|
|
||||||
});
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("body", body);
|
|
||||||
for (const f of files) form.append("files", f, f.name);
|
|
||||||
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
|
|
||||||
method: "POST",
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
read: (id: number, upTo: number) =>
|
|
||||||
request<{ lastReadId: number }>(`/chat/rooms/${id}/read`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ upTo }),
|
|
||||||
}),
|
|
||||||
/** Delete the chat for yourself only (a direct chat leaves your list). */
|
|
||||||
clear: (id: number) =>
|
|
||||||
request<{ hidden: boolean }>(`/chat/rooms/${id}/clear`, { method: "POST" }),
|
|
||||||
/** The group's creator deletes it for everyone. */
|
|
||||||
disband: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}`, { method: "DELETE" }),
|
|
||||||
/** Your own message only (403 otherwise). */
|
|
||||||
deleteMessage: (roomId: number, messageId: number) =>
|
|
||||||
request<{ ok: true }>(`/chat/rooms/${roomId}/messages/${messageId}`, { method: "DELETE" }),
|
|
||||||
fileUrl: (roomId: number, fileId: string) =>
|
|
||||||
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
|
|
||||||
},
|
|
||||||
users: {
|
users: {
|
||||||
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
||||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||||
@@ -3696,7 +3601,11 @@ export const api = {
|
|||||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||||
artifacts: {
|
artifacts: {
|
||||||
list: (
|
list: (
|
||||||
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
params: {
|
||||||
|
scope?: "mine" | "hub" | "withme" | "all" | "unowned";
|
||||||
|
kind?: ArtifactKind;
|
||||||
|
q?: string;
|
||||||
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.scope) qs.set("scope", params.scope);
|
if (params.scope) qs.set("scope", params.scope);
|
||||||
@@ -3706,6 +3615,17 @@ export const api = {
|
|||||||
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
|
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
|
||||||
},
|
},
|
||||||
settings: () => request<ArtifactSettings>("/artifacts/settings"),
|
settings: () => request<ArtifactSettings>("/artifacts/settings"),
|
||||||
|
setExportsEnabled: (exportsEnabled: boolean) =>
|
||||||
|
request<{ installsEnabled: boolean; exportsEnabled: boolean }>("/artifacts/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ exportsEnabled }),
|
||||||
|
}),
|
||||||
|
/** A page's viewable copy, taken now from the file it was published from (if still there). */
|
||||||
|
copy: (id: number) =>
|
||||||
|
request<{ item: Artifact }>(`/artifacts/${id}/copy`, { method: "POST", body: "{}" }),
|
||||||
|
/** Server mode: who the caller may share with — names only. */
|
||||||
|
people: () =>
|
||||||
|
request<{ people: { id: number; username: string; name: string }[] }>("/artifacts/people"),
|
||||||
setInstallsEnabled: (installsEnabled: boolean) =>
|
setInstallsEnabled: (installsEnabled: boolean) =>
|
||||||
request<{ installsEnabled: boolean }>("/artifacts/settings", {
|
request<{ installsEnabled: boolean }>("/artifacts/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -3792,6 +3712,8 @@ export const api = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
|
/** Server mode: the user ids it is shared with by name (replaces the list). */
|
||||||
|
sharedWith?: number[];
|
||||||
/** plugin only: `name@marketplace`. */
|
/** plugin only: `name@marketplace`. */
|
||||||
source?: string;
|
source?: string;
|
||||||
/** plugin only: the marketplace source. */
|
/** plugin only: the marketplace source. */
|
||||||
@@ -3877,6 +3799,14 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
}),
|
}),
|
||||||
|
/** GET /api/ai-tools/member-policy - what members' Claude may run (server mode, S3). */
|
||||||
|
memberPolicy: () => request<MemberPolicyState>("/ai-tools/member-policy"),
|
||||||
|
/** PUT /api/ai-tools/member-policy - an admin's switch. */
|
||||||
|
setMemberPolicy: (allowTests: boolean) =>
|
||||||
|
request<MemberPolicyState>("/ai-tools/member-policy", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ allowTests }),
|
||||||
|
}),
|
||||||
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
||||||
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
||||||
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
||||||
@@ -3933,6 +3863,55 @@ export const api = {
|
|||||||
*
|
*
|
||||||
* No response here ever carries an API key — a saved profile reports only
|
* No response here ever carries an API key — a saved profile reports only
|
||||||
* `hasKey`. */
|
* `hasKey`. */
|
||||||
|
/** A LOCAL AgentHub's link to the company hub (server/routes/remote-client.js). */
|
||||||
|
remoteClient: {
|
||||||
|
/** GET /api/remote-client - enrollment and what was sent; never the token. */
|
||||||
|
status: () => request<RemoteClientStatus>("/remote-client"),
|
||||||
|
/** POST /api/remote-client/login - the password goes to the hub once, not kept. */
|
||||||
|
login: (body: { hub: string; username: string; password: string; deviceName?: string }) =>
|
||||||
|
request<RemoteClientStatus>("/remote-client/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
/** POST /api/remote-client/logout - revoke on the hub, forget the token here. */
|
||||||
|
logout: () =>
|
||||||
|
request<{ ok: true; wasEnrolled: boolean; revokedOnHub?: boolean }>("/remote-client/logout", {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
/** POST /api/remote-client/upload - send the Company-account sessions' new lines now. */
|
||||||
|
upload: () =>
|
||||||
|
request<{
|
||||||
|
sessions: number;
|
||||||
|
bytes: number;
|
||||||
|
errors: { sessionId: string; message: string }[];
|
||||||
|
}>("/remote-client/upload", { method: "POST" }),
|
||||||
|
},
|
||||||
|
/** The hub's remote-client views (server mode, server/hub/). */
|
||||||
|
remoteHub: {
|
||||||
|
/** GET /api/remote/devices - your machines; staff may name `user`. */
|
||||||
|
devices: (user?: number) =>
|
||||||
|
request<{ devices: RemoteDevice[] }>(`/remote/devices${user ? `?user=${user}` : ""}`),
|
||||||
|
/** POST /api/remote/devices/:id/revoke - your own password again. */
|
||||||
|
revoke: (id: number, password: string) =>
|
||||||
|
request<{ ok: true; device: RemoteDevice }>(`/remote/devices/${id}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}),
|
||||||
|
/** GET /api/remote/sessions - Company-account sessions (device › folder). */
|
||||||
|
sessions: (user?: number) =>
|
||||||
|
request<{ sessions: RemoteSessionRow[] }>(`/remote/sessions${user ? `?user=${user}` : ""}`),
|
||||||
|
/** GET /api/remote/audit - who opened your sessions. */
|
||||||
|
audit: () =>
|
||||||
|
request<{
|
||||||
|
entries: {
|
||||||
|
id: number;
|
||||||
|
at: string;
|
||||||
|
sessionId: string;
|
||||||
|
reader: string | null;
|
||||||
|
readerDisplay: string | null;
|
||||||
|
}[];
|
||||||
|
}>("/remote/audit"),
|
||||||
|
},
|
||||||
aiProxies: {
|
aiProxies: {
|
||||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||||
@@ -5309,6 +5288,10 @@ export interface Artifact {
|
|||||||
size: number | null;
|
size: number | null;
|
||||||
shared: boolean;
|
shared: boolean;
|
||||||
sharedAt: string | null;
|
sharedAt: string | null;
|
||||||
|
/** Server mode: the people it is shared with by name (only its owner or an admin gets the list). */
|
||||||
|
sharedWith?: { id: number; name: string }[];
|
||||||
|
/** Server mode: shared with the caller by name. */
|
||||||
|
sharedWithMe?: boolean;
|
||||||
installs: number;
|
installs: number;
|
||||||
/** False once its owner removed it from their library while shared: Hub only. */
|
/** False once its owner removed it from their library while shared: Hub only. */
|
||||||
inLibrary: boolean;
|
inLibrary: boolean;
|
||||||
@@ -5369,6 +5352,13 @@ export interface ArtifactCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
||||||
|
export interface MemberPolicyState {
|
||||||
|
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||||
|
available: boolean;
|
||||||
|
/** May members' Claude run test runners and container commands? Off by default. */
|
||||||
|
allowTests: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OmpSharingState {
|
export interface OmpSharingState {
|
||||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -5382,6 +5372,8 @@ export interface ArtifactSettings {
|
|||||||
serverMode: boolean;
|
serverMode: boolean;
|
||||||
canShare: boolean;
|
canShare: boolean;
|
||||||
installsEnabled: boolean;
|
installsEnabled: boolean;
|
||||||
|
/** Claude panes are asked to save a viewable copy of a Doc / Slides / Design they publish. */
|
||||||
|
exportsEnabled?: boolean;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5694,6 +5686,9 @@ export interface AiProxy {
|
|||||||
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** The remote client's Company account (`agenthub.company`) — the hub's gateway,
|
||||||
|
* added by the client on an enrolled machine; never a saved profile. */
|
||||||
|
company?: boolean;
|
||||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
/** "shared": the host's (an admin's, in server mode), usable by everyone;
|
/** "shared": the host's (an admin's, in server mode), usable by everyone;
|
||||||
@@ -6885,3 +6880,59 @@ export interface PetList {
|
|||||||
featured: FeaturedPet[];
|
featured: FeaturedPet[];
|
||||||
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
|
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An enrolled machine on the hub (server/hub/devices.js) — never its token. */
|
||||||
|
export interface RemoteDevice {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
lastIp: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Company-account session a device registered (server/hub/remote-sessions.js). */
|
||||||
|
export interface RemoteSessionRow {
|
||||||
|
sessionId: string;
|
||||||
|
deviceId: number;
|
||||||
|
deviceName: string;
|
||||||
|
userId: number;
|
||||||
|
cwd: string;
|
||||||
|
tool: string;
|
||||||
|
account: string | null;
|
||||||
|
registeredAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
lastRequestAt: string | null;
|
||||||
|
requests: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
lastUploadAt: string | null;
|
||||||
|
purgedAt: string | null;
|
||||||
|
unmonitored: number;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/remote-client — this local machine's link to the hub. */
|
||||||
|
export interface RemoteClientStatus {
|
||||||
|
enrolled: boolean;
|
||||||
|
expired?: boolean;
|
||||||
|
hub?: string;
|
||||||
|
user?: { id: number; username: string; role: string } | null;
|
||||||
|
device?: { id: number; name: string; expiresAt: string } | null;
|
||||||
|
enrolledAt?: string | null;
|
||||||
|
retainDays?: number | null;
|
||||||
|
uploads?: {
|
||||||
|
sessionId: string;
|
||||||
|
bytes: number;
|
||||||
|
lastSentAt: string | null;
|
||||||
|
ended: number;
|
||||||
|
title: string | null;
|
||||||
|
cwd: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @file artifactFolders — the Artifacts page's user-made folders ("Slides",
|
||||||
|
* "Client A", …): their names and order, and which folder each artifact sits
|
||||||
|
* in. One JSON value under `agenthub.artifacts.folders`, an account preference
|
||||||
|
* key (`lib/userPrefs.ts`, `server/routes/me.js`), so the folders are kept per
|
||||||
|
* ACCOUNT on the server and follow it to any browser. A viewer's arrangement,
|
||||||
|
* not an artifact fact: nothing about an artifact changes, other users never
|
||||||
|
* see these folders, and deleting a folder only takes its artifacts out of it.
|
||||||
|
* Same store shape as `workspaceGroups.ts`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { PREFS_EVENT } from "./workspaceGroups";
|
||||||
|
|
||||||
|
export const ARTIFACT_FOLDERS_KEY = "agenthub.artifacts.folders";
|
||||||
|
export const MAX_FOLDER_NAME = 60;
|
||||||
|
|
||||||
|
export interface ArtifactFolder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactFolders {
|
||||||
|
/** In display order. */
|
||||||
|
folders: ArtifactFolder[];
|
||||||
|
/** Artifact id → folder id. An artifact absent here is in no folder. */
|
||||||
|
of: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: ArtifactFolders = { folders: [], of: {} };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let cache: { raw: string | null; value: ArtifactFolders } = { raw: null, value: EMPTY };
|
||||||
|
|
||||||
|
function readRaw(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(ARTIFACT_FOLDERS_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(raw: string | null): ArtifactFolders {
|
||||||
|
if (!raw) return EMPTY;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as Partial<ArtifactFolders>;
|
||||||
|
const folders = (Array.isArray(v.folders) ? v.folders : []).filter(
|
||||||
|
(f): f is ArtifactFolder =>
|
||||||
|
!!f && typeof f.id === "string" && !!f.id && typeof f.name === "string"
|
||||||
|
);
|
||||||
|
const ids = new Set(folders.map((f) => f.id));
|
||||||
|
const of: Record<string, string> = {};
|
||||||
|
if (v.of && typeof v.of === "object") {
|
||||||
|
for (const [item, fid] of Object.entries(v.of)) {
|
||||||
|
if (typeof fid === "string" && ids.has(fid)) of[item] = fid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { folders, of };
|
||||||
|
} catch {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readArtifactFolders(): ArtifactFolders {
|
||||||
|
const raw = readRaw();
|
||||||
|
if (raw === cache.raw) return cache.value;
|
||||||
|
cache = { raw, value: parse(raw) };
|
||||||
|
return cache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(next: ArtifactFolders): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ARTIFACT_FOLDERS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* disabled storage: the folders live until the page closes */
|
||||||
|
cache = { raw: JSON.stringify(next), value: next };
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(fn: (cur: ArtifactFolders) => ArtifactFolders): void {
|
||||||
|
write(fn(readArtifactFolders()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = (name: string) => name.trim().slice(0, MAX_FOLDER_NAME);
|
||||||
|
|
||||||
|
/** A new folder at the end, holding `itemIds` (moved out of their old folder). Returns its id, or null for a blank name. */
|
||||||
|
export function createFolder(name: string, itemIds: number[] = []): string | null {
|
||||||
|
const clean = cleanName(name);
|
||||||
|
if (!clean) return null;
|
||||||
|
const id = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
update((cur) => {
|
||||||
|
const of = { ...cur.of };
|
||||||
|
for (const item of itemIds) of[item] = id;
|
||||||
|
return { folders: [...cur.folders, { id, name: clean }], of };
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename; a blank name keeps the old one. */
|
||||||
|
export function renameFolder(id: string, name: string): void {
|
||||||
|
const clean = cleanName(name);
|
||||||
|
if (!clean) return;
|
||||||
|
update((cur) => ({
|
||||||
|
...cur,
|
||||||
|
folders: cur.folders.map((f) => (f.id === id ? { ...f, name: clean } : f)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the folder; its artifacts are only taken out of it. */
|
||||||
|
export function deleteFolder(id: string): void {
|
||||||
|
update((cur) => ({
|
||||||
|
folders: cur.folders.filter((f) => f.id !== id),
|
||||||
|
of: Object.fromEntries(Object.entries(cur.of).filter(([, fid]) => fid !== id)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put an artifact in a folder, or (null) take it out of every folder. */
|
||||||
|
export function moveToFolder(itemId: number, folderId: string | null): void {
|
||||||
|
update((cur) => {
|
||||||
|
const of = { ...cur.of };
|
||||||
|
if (folderId && cur.folders.some((f) => f.id === folderId)) of[itemId] = folderId;
|
||||||
|
else delete of[itemId];
|
||||||
|
return { ...cur, of };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder an artifact is in, or null. */
|
||||||
|
export function folderOf(value: ArtifactFolders, itemId: number): ArtifactFolder | null {
|
||||||
|
const fid = value.of[itemId];
|
||||||
|
return (fid && value.folders.find((f) => f.id === fid)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(l: () => void) {
|
||||||
|
listeners.add(l);
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === ARTIFACT_FOLDERS_KEY || e.key === null) l();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
window.addEventListener(PREFS_EVENT, l);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(l);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
window.removeEventListener(PREFS_EVENT, l);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArtifactFolders(): ArtifactFolders {
|
||||||
|
return useSyncExternalStore(subscribe, readArtifactFolders);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @file hiddenProfilesStorage.ts
|
||||||
|
* @description Claude login profiles this browser leaves out of Settings →
|
||||||
|
* AI tools → Profiles, kept in localStorage with the same read/validate/
|
||||||
|
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
|
||||||
|
* profile files, the API and the database are untouched, and another browser
|
||||||
|
* still lists every profile.
|
||||||
|
*
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
|
||||||
|
|
||||||
|
/** A profile's stable key: its account (survives a rename), else its file name. */
|
||||||
|
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
|
||||||
|
return p.accountUuid || p.name || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readHiddenProfiles(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((k): k is string => typeof k === "string" && k.length > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeHiddenProfiles(keys: string[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota / disabled storage */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `keys` with `key` added when absent, removed when present. */
|
||||||
|
export function toggleHidden(keys: string[], key: string): string[] {
|
||||||
|
return keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key];
|
||||||
|
}
|
||||||
+6
-18
@@ -1573,7 +1573,10 @@ export type AlertRuleType =
|
|||||||
| "status_duration"
|
| "status_duration"
|
||||||
| "token_threshold"
|
| "token_threshold"
|
||||||
| "cost_threshold"
|
| "cost_threshold"
|
||||||
| "budget";
|
| "budget"
|
||||||
|
/** Server mode: a user's machine used the company account for a session it
|
||||||
|
* never registered with the hub (no config). */
|
||||||
|
| "remote_unmonitored";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
|
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
|
||||||
@@ -1917,16 +1920,10 @@ export interface WSMessage {
|
|||||||
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
|
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
|
||||||
* remote_source.status → RemoteSourceStatusPayload;
|
* remote_source.status → RemoteSourceStatusPayload;
|
||||||
* remote_data.updated → RemoteDataUpdatedPayload;
|
* remote_data.updated → RemoteDataUpdatedPayload;
|
||||||
* lane_update → { lane?: Lane; removed?: number };
|
* lane_update → { lane?: Lane; removed?: number }. */
|
||||||
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to };
|
|
||||||
* chat_deleted → { roomId, messageId, to }; chat_read → { roomId, userId, lastReadId, to }. */
|
|
||||||
type:
|
type:
|
||||||
| "session_created"
|
| "session_created"
|
||||||
| "session_updated"
|
| "session_updated"
|
||||||
| "chat_message"
|
|
||||||
| "chat_room"
|
|
||||||
| "chat_deleted"
|
|
||||||
| "chat_read"
|
|
||||||
| "agent_created"
|
| "agent_created"
|
||||||
| "agent_updated"
|
| "agent_updated"
|
||||||
| "new_event"
|
| "new_event"
|
||||||
@@ -1973,16 +1970,7 @@ export interface WSMessage {
|
|||||||
| { lane?: Lane; removed?: number }
|
| { lane?: Lane; removed?: number }
|
||||||
| LaneHookOutputPayload
|
| LaneHookOutputPayload
|
||||||
| LaneRuntimePayload
|
| LaneRuntimePayload
|
||||||
| LaneHookResultPayload
|
| LaneHookResultPayload;
|
||||||
/** chat_message / chat_room (lib/api.ts ChatMessage). */
|
|
||||||
| {
|
|
||||||
roomId: number;
|
|
||||||
message?: unknown;
|
|
||||||
messageId?: number;
|
|
||||||
userId?: number;
|
|
||||||
lastReadId?: number;
|
|
||||||
to?: number[];
|
|
||||||
};
|
|
||||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||||
* same instant the underlying event occurred). */
|
* same instant the underlying event occurred). */
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const PREF_KEYS = [
|
|||||||
"agenthub.markdown.view",
|
"agenthub.markdown.view",
|
||||||
"agenthub.workspace.groups",
|
"agenthub.workspace.groups",
|
||||||
"agenthub.changes.innerRepos",
|
"agenthub.changes.innerRepos",
|
||||||
|
"agenthub.artifacts.folders",
|
||||||
] as const;
|
] as const;
|
||||||
const SYNCED = new Set<string>(PREF_KEYS);
|
const SYNCED = new Set<string>(PREF_KEYS);
|
||||||
const PUSH_DELAY_MS = 400;
|
const PUSH_DELAY_MS = 400;
|
||||||
|
|||||||
+583
-24
@@ -53,12 +53,14 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
|
Folder,
|
||||||
|
FolderPlus,
|
||||||
Globe,
|
Globe,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
@@ -83,6 +85,8 @@ import { eventBus } from "../lib/eventBus";
|
|||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { confirmDialog } from "../lib/confirm";
|
import { confirmDialog } from "../lib/confirm";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
|
import { Checkbox } from "../components/Checkbox";
|
||||||
|
import { isSubmitEnter } from "../lib/keys";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { timeAgo } from "../lib/format";
|
import { timeAgo } from "../lib/format";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||||
@@ -95,6 +99,17 @@ import {
|
|||||||
splitSkillMd,
|
splitSkillMd,
|
||||||
} from "../components/artifacts/ArtifactAddPanels";
|
} from "../components/artifacts/ArtifactAddPanels";
|
||||||
import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview";
|
import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview";
|
||||||
|
import { HtmlFrame } from "../components/HtmlFrame";
|
||||||
|
import { PdfView } from "../components/PdfView";
|
||||||
|
import {
|
||||||
|
MAX_FOLDER_NAME,
|
||||||
|
createFolder,
|
||||||
|
deleteFolder,
|
||||||
|
folderOf,
|
||||||
|
moveToFolder,
|
||||||
|
renameFolder,
|
||||||
|
useArtifactFolders,
|
||||||
|
} from "../lib/artifactFolders";
|
||||||
|
|
||||||
// The storefront's shelf order: what installs first, what is read after.
|
// The storefront's shelf order: what installs first, what is read after.
|
||||||
const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"];
|
const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"];
|
||||||
@@ -110,22 +125,41 @@ const fmtDate = (iso: string) =>
|
|||||||
new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
|
||||||
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
||||||
// What the sandboxed preview can show: the server's typed list minus PDF.
|
// What the preview can show: the sandboxed types, plus PDF drawn by pdf.js.
|
||||||
const PREVIEWABLE =
|
const PREVIEWABLE =
|
||||||
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i;
|
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav|pdf)$/i;
|
||||||
|
const PDF = /\.pdf$/i;
|
||||||
|
|
||||||
/** What a row can do, worked out once for its card, menu and detail sheet. */
|
/** What a row can do, worked out once for its card, menu and detail sheet. */
|
||||||
|
/** What a dragged artifact carries: its id, for a folder chip to take. */
|
||||||
|
const DRAG_TYPE = "application/x-agenthub-artifact";
|
||||||
|
const dragProps = (a: Artifact) => ({
|
||||||
|
draggable: true,
|
||||||
|
onDragStart: (e: React.DragEvent) => {
|
||||||
|
e.dataTransfer.setData(DRAG_TYPE, String(a.id));
|
||||||
|
e.dataTransfer.setData("text/plain", a.title);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared at all: with everyone (the Hub) or with chosen people. */
|
||||||
|
const isShared = (a: Artifact) => a.shared || (a.sharedWith?.length ?? 0) > 0;
|
||||||
|
|
||||||
function capabilities(a: Artifact) {
|
function capabilities(a: Artifact) {
|
||||||
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
|
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
|
||||||
return {
|
return {
|
||||||
previewPath,
|
previewPath,
|
||||||
canPreview:
|
canPreview:
|
||||||
a.files.length > 0 &&
|
a.files.length > 0 &&
|
||||||
(a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))),
|
(a.kind === "skill" ||
|
||||||
|
((a.kind === "file" || a.kind === "page") && PREVIEWABLE.test(previewPath))),
|
||||||
|
// A page with no copy yet that was published from a file: one may still
|
||||||
|
// be taken (`POST /:id/copy`) when someone asks to view it.
|
||||||
|
mayCopy: a.kind === "page" && a.files.length === 0 && !!a.source,
|
||||||
canDownload: a.kind === "file" && a.files.length > 0,
|
canDownload: a.kind === "file" && a.files.length > 0,
|
||||||
installable: a.kind === "skill" || a.kind === "plugin",
|
installable: a.kind === "skill" || a.kind === "plugin",
|
||||||
// Its owner removed it from their library; it lives on in the Hub only.
|
// Its owner removed it from their library; it lives on in the Hub only.
|
||||||
hubOnly: a.mine && a.shared && !a.inLibrary,
|
hubOnly: a.mine && isShared(a) && !a.inLibrary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,14 +184,25 @@ const footAction =
|
|||||||
export function Artifacts() {
|
export function Artifacts() {
|
||||||
const { t } = useTranslation("artifacts");
|
const { t } = useTranslation("artifacts");
|
||||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||||
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
|
const [tab, setTab] = useState<"all" | "mine" | "hub" | "withme" | "unowned">("all");
|
||||||
const { isAdmin } = usePermissions();
|
const { isAdmin } = usePermissions();
|
||||||
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
|
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
|
||||||
const [unownedCount, setUnownedCount] = useState(0);
|
const [unownedCount, setUnownedCount] = useState(0);
|
||||||
const [users, setUsers] = useState<ServerUserUsage[]>([]);
|
const [users, setUsers] = useState<ServerUserUsage[]>([]);
|
||||||
const [kind, setKind] = useState<ArtifactKind | "">("");
|
const [kind, setKind] = useState<ArtifactKind | "">("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
const [loaded, setItems] = useState<Artifact[] | null>(null);
|
||||||
|
// Folders are this account's own arrangement (`lib/artifactFolders.ts`).
|
||||||
|
const folders = useArtifactFolders();
|
||||||
|
const [folder, setFolder] = useState<string>("");
|
||||||
|
const [sharing, setSharing] = useState<Artifact | null>(null);
|
||||||
|
const [moving, setMoving] = useState<Artifact | null>(null);
|
||||||
|
const activeFolder = folders.folders.some((f) => f.id === folder) ? folder : "";
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
loaded && activeFolder ? loaded.filter((a) => folders.of[a.id] === activeFolder) : loaded,
|
||||||
|
[loaded, activeFolder, folders]
|
||||||
|
);
|
||||||
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
||||||
const [addMenu, setAddMenu] = useState(false);
|
const [addMenu, setAddMenu] = useState(false);
|
||||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -261,12 +306,16 @@ export function Artifacts() {
|
|||||||
|
|
||||||
const server = !!settings?.serverMode;
|
const server = !!settings?.serverMode;
|
||||||
|
|
||||||
|
// Share opens the dialog: everyone (the Hub), chosen people, or nobody. A
|
||||||
|
// Hub-only row (its owner already removed it from their library) is
|
||||||
|
// deleted by unsharing it, so its one action is that, behind a confirm.
|
||||||
const share = async (a: Artifact) => {
|
const share = async (a: Artifact) => {
|
||||||
// A Hub-only row (its owner already removed it from their library) is
|
if (!capabilities(a).hubOnly) {
|
||||||
// deleted by unsharing it, so that asks first.
|
setDetailId(null);
|
||||||
|
setSharing(a);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
a.shared &&
|
|
||||||
!a.inLibrary &&
|
|
||||||
!(await confirmDialog({
|
!(await confirmDialog({
|
||||||
title: t("hubRemoveTitle", { title: a.title }),
|
title: t("hubRemoveTitle", { title: a.title }),
|
||||||
message: t("hubRemoveBody"),
|
message: t("hubRemoveBody"),
|
||||||
@@ -275,8 +324,8 @@ export function Artifacts() {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
try {
|
try {
|
||||||
await api.artifacts.update(a.id, { shared: !a.shared });
|
await api.artifacts.update(a.id, { shared: false, sharedWith: [] });
|
||||||
toast.success(t(a.shared ? "unshareDone" : "shareDone"));
|
toast.success(t("unshareDone"));
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err);
|
toast.error(err);
|
||||||
@@ -495,6 +544,10 @@ export function Artifacts() {
|
|||||||
const actionsFor = (a: Artifact): CardActions => ({
|
const actionsFor = (a: Artifact): CardActions => ({
|
||||||
onOpen: () => setDetailId(a.id),
|
onOpen: () => setDetailId(a.id),
|
||||||
onShare: () => void share(a),
|
onShare: () => void share(a),
|
||||||
|
onMove: () => {
|
||||||
|
setDetailId(null);
|
||||||
|
setMoving(a);
|
||||||
|
},
|
||||||
onReshare: () => void reshare(a),
|
onReshare: () => void reshare(a),
|
||||||
onRename: () => {
|
onRename: () => {
|
||||||
setDetailId(null);
|
setDetailId(null);
|
||||||
@@ -506,7 +559,20 @@ export function Artifacts() {
|
|||||||
onUninstall: () => void uninstall(a),
|
onUninstall: () => void uninstall(a),
|
||||||
onPreview: (path) => {
|
onPreview: (path) => {
|
||||||
setDetailId(null);
|
setDetailId(null);
|
||||||
|
if (path || !capabilities(a).mayCopy) {
|
||||||
setPreview({ item: a, path });
|
setPreview({ item: a, path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// An older page: take its copy now, then show it.
|
||||||
|
api.artifacts
|
||||||
|
.copy(a.id)
|
||||||
|
.then(({ item }) => {
|
||||||
|
const c = capabilities(item);
|
||||||
|
if (c.canPreview) setPreview({ item, path: c.previewPath });
|
||||||
|
else toast.error(t("page.noCopy"));
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((err) => toast.error(err));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -609,20 +675,21 @@ export function Artifacts() {
|
|||||||
// shared (only an admin is shown those) gets a section of its own, so
|
// shared (only an admin is shown those) gets a section of its own, so
|
||||||
// "Shared by others" never holds a private row.
|
// "Shared by others" never holds a private row.
|
||||||
const halves =
|
const halves =
|
||||||
server && items && tab !== "unowned"
|
server && items && tab !== "unowned" && tab !== "withme"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: `${tab}.others`,
|
key: `${tab}.others`,
|
||||||
rows: items.filter((a) => !a.mine && (a.shared || tab === "mine")),
|
rows: items.filter((a) => !a.mine && (a.shared || a.sharedWithMe || tab === "mine")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "unshared",
|
key: "unshared",
|
||||||
rows: tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared),
|
rows:
|
||||||
|
tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared && !a.sharedWithMe),
|
||||||
},
|
},
|
||||||
{ key: `${tab}.own`, rows: items.filter((a) => a.mine) },
|
{ key: `${tab}.own`, rows: items.filter((a) => a.mine) },
|
||||||
].filter((h) => h.rows.length > 0 || h.key !== "unshared")
|
].filter((h) => h.rows.length > 0 || h.key !== "unshared")
|
||||||
: null;
|
: null;
|
||||||
const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null);
|
const detail = detailId === null ? null : (loaded?.find((a) => a.id === detailId) ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
|
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
|
||||||
@@ -682,7 +749,7 @@ export function Artifacts() {
|
|||||||
className="-mb-1 flex gap-1 overflow-x-auto border-b border-border"
|
className="-mb-1 flex gap-1 overflow-x-auto border-b border-border"
|
||||||
data-testid="artifacts-scope"
|
data-testid="artifacts-scope"
|
||||||
>
|
>
|
||||||
{(["all", "mine", "hub"] as const).map((v) => (
|
{(["all", "mine", "hub", "withme"] as const).map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -773,12 +840,31 @@ export function Artifacts() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FolderBar
|
||||||
|
folders={folders.folders}
|
||||||
|
active={activeFolder}
|
||||||
|
counts={(id) => (loaded ?? []).filter((a) => folders.of[a.id] === id).length}
|
||||||
|
onPick={setFolder}
|
||||||
|
onDropItem={(id, fid) => {
|
||||||
|
moveToFolder(id, fid);
|
||||||
|
const f = fid ? folders.folders.find((x) => x.id === fid) : null;
|
||||||
|
toast.success(f ? t("folders.moved", { name: f.name }) : t("folders.takenOut"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{items === null ? (
|
{items === null ? (
|
||||||
<SkeletonGrid label={t("loading")} />
|
<SkeletonGrid label={t("loading")} />
|
||||||
) : items.length === 0 && tab === "unowned" ? (
|
) : items.length === 0 && tab === "unowned" ? (
|
||||||
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
||||||
{t("unowned.empty")}
|
{t("unowned.empty")}
|
||||||
</p>
|
</p>
|
||||||
|
) : items.length === 0 && (tab === "withme" || activeFolder) ? (
|
||||||
|
<p
|
||||||
|
className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted"
|
||||||
|
data-testid="artifacts-empty-filtered"
|
||||||
|
>
|
||||||
|
{t(activeFolder ? "folders.empty" : "withme.empty")}
|
||||||
|
</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
hub={tab === "hub"}
|
hub={tab === "hub"}
|
||||||
@@ -846,6 +932,24 @@ export function Artifacts() {
|
|||||||
)}
|
)}
|
||||||
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
|
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
|
||||||
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
|
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
|
||||||
|
{sharing && (
|
||||||
|
<ShareDialog
|
||||||
|
item={sharing}
|
||||||
|
onClose={() => setSharing(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setSharing(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{moving && (
|
||||||
|
<MoveDialog
|
||||||
|
item={moving}
|
||||||
|
current={folderOf(folders, moving.id)?.id ?? null}
|
||||||
|
folders={folders.folders}
|
||||||
|
onClose={() => setMoving(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{renaming && (
|
{renaming && (
|
||||||
<RenameDialog
|
<RenameDialog
|
||||||
item={renaming}
|
item={renaming}
|
||||||
@@ -1131,6 +1235,8 @@ function EmptyState({
|
|||||||
type CardActions = {
|
type CardActions = {
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
|
/** Put it in one of the viewer's own folders. */
|
||||||
|
onMove: () => void;
|
||||||
/** A skill of your own: its newest version, shared (installers get Update). */
|
/** A skill of your own: its newest version, shared (installers get Update). */
|
||||||
onReshare: () => void;
|
onReshare: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
@@ -1191,6 +1297,20 @@ function PrimaryAction({
|
|||||||
{t("install")}
|
{t("install")}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
// A page with a copy is viewed here, by anyone it is shared with; the
|
||||||
|
// claude.ai link (⋯ → Open) needs the publishing account.
|
||||||
|
if (a.kind === "page" && (c.canPreview || c.mayCopy))
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={smallPrimary}
|
||||||
|
onClick={() => onPreview(c.canPreview ? c.previewPath : "")}
|
||||||
|
title={t("page.viewHint")}
|
||||||
|
data-testid="artifact-view"
|
||||||
|
>
|
||||||
|
{t("page.view")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
if (a.kind === "page" && a.url)
|
if (a.kind === "page" && a.url)
|
||||||
return (
|
return (
|
||||||
<a href={a.url} target="_blank" rel="noopener noreferrer" className={smallPrimary}>
|
<a href={a.url} target="_blank" rel="noopener noreferrer" className={smallPrimary}>
|
||||||
@@ -1251,11 +1371,25 @@ function Badges({ item: a, server }: { item: Artifact; server: boolean }) {
|
|||||||
{t("hubOnly")}
|
{t("hubOnly")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{server && !a.shared && (
|
{server && !isShared(a) && !a.sharedWithMe && (
|
||||||
<span className="badge shrink-0" title={t("privateHint")}>
|
<span className="badge shrink-0" title={t("privateHint")}>
|
||||||
{t("private")}
|
{t("private")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{server && !a.shared && (a.sharedWith?.length ?? 0) > 0 && (
|
||||||
|
<span
|
||||||
|
className="badge shrink-0 border-accent/30 bg-accent-muted text-accent"
|
||||||
|
title={a.sharedWith!.map((p) => p.name).join(", ")}
|
||||||
|
data-testid={`shared-people-${a.id}`}
|
||||||
|
>
|
||||||
|
{t("sharedWithCount", { count: a.sharedWith!.length })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{server && !a.shared && a.sharedWithMe && (
|
||||||
|
<span className="badge shrink-0 border-accent/30 bg-accent-muted text-accent">
|
||||||
|
{t("sharedWithMe")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{a.auto && (
|
{a.auto && (
|
||||||
<span className="badge shrink-0" title={t("autoHint")}>
|
<span className="badge shrink-0" title={t("autoHint")}>
|
||||||
{t("auto")}
|
{t("auto")}
|
||||||
@@ -1316,6 +1450,7 @@ function ArtifactMenu({
|
|||||||
menu,
|
menu,
|
||||||
onOpen,
|
onOpen,
|
||||||
onShare,
|
onShare,
|
||||||
|
onMove,
|
||||||
onReshare,
|
onReshare,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -1369,12 +1504,13 @@ function ArtifactMenu({
|
|||||||
{t("uninstall")}
|
{t("uninstall")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
<MenuItem onSelect={pick(onMove)}>{t("folders.move")}</MenuItem>
|
||||||
{a.canChange && (
|
{a.canChange && (
|
||||||
<>
|
<>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{server && (
|
{server && (
|
||||||
<MenuItem danger={hubOnly} onSelect={pick(onShare)}>
|
<MenuItem danger={hubOnly} onSelect={pick(onShare)}>
|
||||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
{t(hubOnly ? "hubRemove" : isShared(a) ? "sharing" : "share")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{server && a.kind === "skill" && (
|
{server && a.kind === "skill" && (
|
||||||
@@ -1419,6 +1555,7 @@ function ArtifactRow({ inPack, ...props }: ItemProps & { inPack?: boolean }) {
|
|||||||
className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-2/60"
|
className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-2/60"
|
||||||
data-testid={`artifact-${a.id}`}
|
data-testid={`artifact-${a.id}`}
|
||||||
onContextMenu={(e) => menu.open(e, null)}
|
onContextMenu={(e) => menu.open(e, null)}
|
||||||
|
{...dragProps(a)}
|
||||||
>
|
>
|
||||||
<KindIcon kind={a.kind} />
|
<KindIcon kind={a.kind} />
|
||||||
<button
|
<button
|
||||||
@@ -1473,6 +1610,7 @@ function ArtifactCard({ as = "li", ...props }: ItemProps & { as?: "li" | "div" }
|
|||||||
className="card flex flex-col gap-2.5 p-4 transition-colors hover:border-border-light"
|
className="card flex flex-col gap-2.5 p-4 transition-colors hover:border-border-light"
|
||||||
data-testid={`artifact-${a.id}`}
|
data-testid={`artifact-${a.id}`}
|
||||||
onContextMenu={(e: React.MouseEvent) => menu.open(e, null)}
|
onContextMenu={(e: React.MouseEvent) => menu.open(e, null)}
|
||||||
|
{...dragProps(a)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<KindIcon kind={a.kind} />
|
<KindIcon kind={a.kind} />
|
||||||
@@ -1567,9 +1705,18 @@ function DetailDialog({
|
|||||||
{server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)}
|
{server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)}
|
||||||
{a.source ? ` · ${a.source}` : ""}
|
{a.source ? ` · ${a.source}` : ""}
|
||||||
</p>
|
</p>
|
||||||
{a.shared && (
|
{(isShared(a) || a.sharedWithMe) && (
|
||||||
<span className="badge mt-2 border-accent/30 bg-accent-muted text-accent">
|
<span
|
||||||
{t(hubOnly ? "hubOnly" : "shared")}
|
className="badge mt-2 border-accent/30 bg-accent-muted text-accent"
|
||||||
|
title={a.sharedWith?.map((p) => p.name).join(", ") || undefined}
|
||||||
|
>
|
||||||
|
{hubOnly
|
||||||
|
? t("hubOnly")
|
||||||
|
: a.shared
|
||||||
|
? t("shared")
|
||||||
|
: a.sharedWithMe
|
||||||
|
? t("sharedWithMe")
|
||||||
|
: t("sharedWithCount", { count: a.sharedWith?.length ?? 0 })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
@@ -1695,7 +1842,7 @@ function DetailDialog({
|
|||||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||||
{server && (
|
{server && (
|
||||||
<button type="button" className={secondary} onClick={onShare}>
|
<button type="button" className={secondary} onClick={onShare}>
|
||||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
{t(hubOnly ? "hubRemove" : isShared(a) ? "sharing" : "share")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{server && a.kind === "skill" && (
|
{server && a.kind === "skill" && (
|
||||||
@@ -2107,6 +2254,401 @@ function RenameDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who an artifact is shared with: everyone (the Hub), chosen people, or nobody. */
|
||||||
|
function ShareDialog({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
item: Artifact;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
const initialPeople = (item.sharedWith ?? []).map((p) => p.id);
|
||||||
|
const [mode, setMode] = useState<"nobody" | "everyone" | "people">(
|
||||||
|
item.shared ? "everyone" : "people"
|
||||||
|
);
|
||||||
|
const [picked, setPicked] = useState<Set<number>>(new Set(initialPeople));
|
||||||
|
const [people, setPeople] = useState<{ id: number; username: string; name: string }[] | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
api.artifacts
|
||||||
|
.people()
|
||||||
|
.then((r) => setPeople(r.people))
|
||||||
|
.catch((err) => {
|
||||||
|
setPeople([]);
|
||||||
|
toast.error(err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const shown = (people ?? []).filter((p) => {
|
||||||
|
const f = filter.trim().toLowerCase();
|
||||||
|
return !f || p.name.toLowerCase().includes(f) || p.username.toLowerCase().includes(f);
|
||||||
|
});
|
||||||
|
const toggle = (id: number, on: boolean) =>
|
||||||
|
setPicked((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const target =
|
||||||
|
mode === "everyone"
|
||||||
|
? { shared: true, sharedWith: [] as number[] }
|
||||||
|
: mode === "people"
|
||||||
|
? { shared: false, sharedWith: [...picked] }
|
||||||
|
: { shared: false, sharedWith: [] as number[] };
|
||||||
|
const blocked = busy || (mode === "people" && picked.size === 0);
|
||||||
|
const save = async () => {
|
||||||
|
if (blocked) return;
|
||||||
|
// Out of its owner's library, a row that nobody can see any more is
|
||||||
|
// deleted: that asks first.
|
||||||
|
const goneForGood =
|
||||||
|
item.mine && !item.inLibrary && !target.shared && target.sharedWith.length === 0;
|
||||||
|
if (
|
||||||
|
goneForGood &&
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("hubRemoveTitle", { title: item.title }),
|
||||||
|
message: t("hubRemoveBody"),
|
||||||
|
confirmLabel: t("hubRemove"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.artifacts.update(item.id, target);
|
||||||
|
toast.success(
|
||||||
|
target.shared
|
||||||
|
? t("shareDone")
|
||||||
|
: target.sharedWith.length
|
||||||
|
? t("sharePeopleDone", { count: target.sharedWith.length })
|
||||||
|
: t("unshareDone")
|
||||||
|
);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const radio = (v: typeof mode, label: string, hint: string) => (
|
||||||
|
<label className="flex cursor-pointer items-start gap-2 rounded-md p-2 hover:bg-surface-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="share-mode"
|
||||||
|
className="mt-1"
|
||||||
|
checked={mode === v}
|
||||||
|
onChange={() => setMode(v)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm text-fg-primary">{label}</span>
|
||||||
|
<span className="block text-[11px] text-fg-muted">{hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Modal title={t("shareTitle", { title: item.title })} onClose={onClose}>
|
||||||
|
<div className="space-y-3" data-testid="share-dialog">
|
||||||
|
<div role="radiogroup" aria-label={t("shareTitle", { title: item.title })}>
|
||||||
|
{radio("people", t("shareMode.people"), t("shareMode.peopleHint"))}
|
||||||
|
{radio("everyone", t("shareMode.everyone"), t("shareMode.everyoneHint"))}
|
||||||
|
{(item.shared || initialPeople.length > 0) &&
|
||||||
|
radio("nobody", t("shareMode.nobody"), t("shareMode.nobodyHint"))}
|
||||||
|
</div>
|
||||||
|
{mode === "people" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
className="input w-full text-sm"
|
||||||
|
placeholder={t("sharePeople.search")}
|
||||||
|
aria-label={t("sharePeople.search")}
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
{people === null ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("loading")}</p>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("sharePeople.none")}</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
className="max-h-64 space-y-0.5 overflow-y-auto rounded-lg border border-border p-1"
|
||||||
|
data-testid="share-people"
|
||||||
|
>
|
||||||
|
{shown.map((p) => (
|
||||||
|
<li key={p.id} className="rounded-md px-2 py-1 hover:bg-surface-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={picked.has(p.id)}
|
||||||
|
onChange={(v) => toggle(p.id, v)}
|
||||||
|
label={
|
||||||
|
<span className="text-sm text-fg-primary">
|
||||||
|
{p.name}
|
||||||
|
{p.name !== p.username && (
|
||||||
|
<span className="ml-1 text-xs text-fg-muted">@{p.username}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t("sharePeople.picked", { count: picked.size })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
disabled={blocked}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{t("shareSave")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put one artifact in one of the viewer's own folders (or a new one), or take it out. */
|
||||||
|
function MoveDialog({
|
||||||
|
item,
|
||||||
|
current,
|
||||||
|
folders,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
item: Artifact;
|
||||||
|
current: string | null;
|
||||||
|
folders: { id: string; name: string }[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const pick = (id: string | null) => {
|
||||||
|
moveToFolder(item.id, id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const create = () => {
|
||||||
|
const id = createFolder(name, [item.id]);
|
||||||
|
if (id) onClose();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal title={t("folders.moveTitle", { title: item.title })} onClose={onClose}>
|
||||||
|
<div className="space-y-3" data-testid="move-dialog">
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("folders.private")}</p>
|
||||||
|
{folders.length > 0 && (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{folders.map((f) => (
|
||||||
|
<li key={f.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={current === f.id}
|
||||||
|
onClick={() => pick(f.id)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-surface-2 ${
|
||||||
|
current === f.id ? "text-accent" : "text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Folder className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input min-w-0 flex-1 text-sm"
|
||||||
|
placeholder={t("folders.newName")}
|
||||||
|
aria-label={t("folders.newName")}
|
||||||
|
maxLength={MAX_FOLDER_NAME}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
create();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
disabled={!name.trim()}
|
||||||
|
onClick={create}
|
||||||
|
>
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
{t("folders.createHere")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
|
{current ? (
|
||||||
|
<button type="button" className="btn-ghost text-sm" onClick={() => pick(null)}>
|
||||||
|
{t("folders.takeOut")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn-ghost text-sm" onClick={onClose}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The viewer's folders as filter chips, with new / rename / delete. */
|
||||||
|
function FolderBar({
|
||||||
|
folders,
|
||||||
|
active,
|
||||||
|
counts,
|
||||||
|
onPick,
|
||||||
|
onDropItem,
|
||||||
|
}: {
|
||||||
|
folders: { id: string; name: string }[];
|
||||||
|
active: string;
|
||||||
|
counts: (id: string) => number;
|
||||||
|
onPick: (id: string) => void;
|
||||||
|
/** An artifact dropped on a chip: that folder, or (null, "All folders") none. */
|
||||||
|
onDropItem: (itemId: number, folderId: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
// The chip an artifact is dragged over ("" = All folders), lit as a target.
|
||||||
|
const [over, setOver] = useState<string | null>(null);
|
||||||
|
const dropProps = (target: string) => ({
|
||||||
|
onDragOver: (e: React.DragEvent) => {
|
||||||
|
if (!e.dataTransfer.types.includes(DRAG_TYPE)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (over !== target) setOver(target);
|
||||||
|
},
|
||||||
|
onDragLeave: () => setOver((cur) => (cur === target ? null : cur)),
|
||||||
|
onDrop: (e: React.DragEvent) => {
|
||||||
|
setOver(null);
|
||||||
|
const id = Number(e.dataTransfer.getData(DRAG_TYPE));
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onDropItem(id, target || null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const lit = (target: string) => (over === target ? " ring-2 ring-accent bg-accent-muted" : "");
|
||||||
|
// `new`: naming a new folder; a folder id: renaming that one.
|
||||||
|
const [editing, setEditing] = useState<"new" | string | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const begin = (what: "new" | string, start = "") => {
|
||||||
|
setEditing(what);
|
||||||
|
setName(start);
|
||||||
|
};
|
||||||
|
const commit = () => {
|
||||||
|
if (editing === "new") {
|
||||||
|
const id = createFolder(name);
|
||||||
|
if (id) onPick(id);
|
||||||
|
} else if (editing) renameFolder(editing, name);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
const remove = async (f: { id: string; name: string }) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("folders.deleteTitle", { name: f.name }),
|
||||||
|
message: t("folders.deleteBody"),
|
||||||
|
confirmLabel: t("folders.delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
deleteFolder(f.id);
|
||||||
|
if (active === f.id) onPick("");
|
||||||
|
};
|
||||||
|
const chip = (on: boolean) =>
|
||||||
|
`inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap transition-colors ${
|
||||||
|
on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary"
|
||||||
|
}`;
|
||||||
|
const activeFolder = folders.find((f) => f.id === active);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1" data-testid="artifact-folders">
|
||||||
|
<Folder className="mr-1 h-3.5 w-3.5 text-fg-muted" aria-hidden />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={!active}
|
||||||
|
className={chip(!active) + lit("")}
|
||||||
|
onClick={() => onPick("")}
|
||||||
|
title={folders.length ? t("folders.dropOutHint") : undefined}
|
||||||
|
{...dropProps("")}
|
||||||
|
>
|
||||||
|
{t("folders.all")}
|
||||||
|
</button>
|
||||||
|
{folders.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active === f.id}
|
||||||
|
className={chip(active === f.id) + lit(f.id)}
|
||||||
|
onClick={() => onPick(f.id)}
|
||||||
|
title={t("folders.dropHint")}
|
||||||
|
data-testid={`artifact-folder-${f.id}`}
|
||||||
|
{...dropProps(f.id)}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
<span className="text-fg-muted">{counts(f.id)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
className="input h-7 w-40 text-xs"
|
||||||
|
autoFocus
|
||||||
|
maxLength={MAX_FOLDER_NAME}
|
||||||
|
aria-label={t(editing === "new" ? "folders.newName" : "folders.rename")}
|
||||||
|
placeholder={t("folders.newName")}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={chip(false)}
|
||||||
|
onClick={() => begin("new")}
|
||||||
|
data-testid="artifact-folder-new"
|
||||||
|
>
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
{t("folders.new")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activeFolder && !editing && (
|
||||||
|
<span className="ml-1 flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost px-2 py-1 text-xs"
|
||||||
|
onClick={() => begin(activeFolder.id, activeFolder.name)}
|
||||||
|
>
|
||||||
|
{t("folders.rename")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost px-2 py-1 text-xs text-status-danger"
|
||||||
|
onClick={() => void remove(activeFolder)}
|
||||||
|
>
|
||||||
|
{t("folders.delete")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Text the dashboard renders itself: Markdown through `MarkdownPreview`, the
|
// Text the dashboard renders itself: Markdown through `MarkdownPreview`, the
|
||||||
// rest as code. A sandboxed iframe would draw a text/plain body with the
|
// rest as code. A sandboxed iframe would draw a text/plain body with the
|
||||||
// browser's own colours — light text on our white frame in a dark theme.
|
// browser's own colours — light text on our white frame in a dark theme.
|
||||||
@@ -2287,6 +2829,23 @@ function PreviewDialog({
|
|||||||
<video src={url} controls className="max-h-full max-w-full" />
|
<video src={url} controls className="max-h-full max-w-full" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : PDF.test(current) ? (
|
||||||
|
<PdfView url={url} title={current} />
|
||||||
|
) : html && item.kind === "page" && !/\.svg$/i.test(current) ? (
|
||||||
|
// A page's copy is an app: it runs, in the chat artifacts'
|
||||||
|
// sandbox (scripts, never the dashboard's origin), with its
|
||||||
|
// own files read from the copy.
|
||||||
|
<HtmlFrame
|
||||||
|
path={current}
|
||||||
|
title={item.title}
|
||||||
|
className="h-full w-full"
|
||||||
|
testId="artifact-page-frame"
|
||||||
|
fetchFile={(rel) =>
|
||||||
|
fetch(api.artifacts.rawUrl(item.id, rel), { credentials: "same-origin" }).then(
|
||||||
|
(r) => (r.ok ? r.blob() : Promise.reject(new Error(String(r.status))))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : IMAGE.test(current) && !html ? (
|
) : IMAGE.test(current) && !html ? (
|
||||||
<img src={url} alt={current} className="h-full w-full object-contain p-4" />
|
<img src={url} alt={current} className="h-full w-full object-contain p-4" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
|||||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||||
|
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||||
|
import { CompanyHubSettings } from "../components/settings/CompanyHubSettings";
|
||||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||||
@@ -317,6 +319,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
<ArtifactsHubSettings />
|
<ArtifactsHubSettings />
|
||||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||||
<OmpSharingSettings />
|
<OmpSharingSettings />
|
||||||
|
{/* Members' test runs on this box: server mode + S3, an admin's. */}
|
||||||
|
<MemberPolicySettings />
|
||||||
|
{/* The company hub: enroll this machine (local) / your machines (hub). */}
|
||||||
|
<CompanyHubSettings />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import { api, type Artifact } from "../../lib/api";
|
|||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
|
||||||
vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
||||||
|
// pdf.js draws on a canvas jsdom does not have; the viewer is its own unit.
|
||||||
|
vi.mock("../../components/PdfView", () => ({
|
||||||
|
PdfView: ({ title }: { title: string }) => <div data-testid="pdf-view">{title}</div>,
|
||||||
|
}));
|
||||||
vi.mock("../../lib/api", () => ({
|
vi.mock("../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
lanes: { list: vi.fn() },
|
lanes: { list: vi.fn() },
|
||||||
@@ -35,6 +39,8 @@ vi.mock("../../lib/api", () => ({
|
|||||||
settings: vi.fn(),
|
settings: vi.fn(),
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
people: vi.fn(),
|
||||||
|
copy: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
install: vi.fn(),
|
install: vi.fn(),
|
||||||
updateInstalled: vi.fn(),
|
updateInstalled: vi.fn(),
|
||||||
@@ -386,6 +392,157 @@ describe("Artifacts", () => {
|
|||||||
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shares with the people picked, never with everyone unless asked", async () => {
|
||||||
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||||
|
serverMode: true,
|
||||||
|
canShare: true,
|
||||||
|
installsEnabled: true,
|
||||||
|
canManage: false,
|
||||||
|
});
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({ items: [item({ id: 4, title: "deck" })] });
|
||||||
|
vi.mocked(api.artifacts.people).mockResolvedValue({
|
||||||
|
people: [
|
||||||
|
{ id: 7, username: "ann", name: "Ann" },
|
||||||
|
{ id: 8, username: "bob", name: "Bob" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(api.artifacts.update).mockClear();
|
||||||
|
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 4 }) } as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
const card = await screen.findByTestId("artifact-4");
|
||||||
|
await menuAction(user, "Share", card);
|
||||||
|
const dialog = await screen.findByTestId("share-dialog");
|
||||||
|
const save = within(dialog).getByRole("button", { name: "Save" });
|
||||||
|
expect(save).toBeDisabled(); // nobody picked yet
|
||||||
|
await within(dialog).findByText("Ann");
|
||||||
|
await user.type(within(dialog).getByLabelText("Find a person"), "an");
|
||||||
|
expect(within(dialog).queryByText("Bob")).toBeNull();
|
||||||
|
await user.click(within(dialog).getByRole("checkbox", { name: /Ann/ }));
|
||||||
|
await user.click(save);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.artifacts.update).toHaveBeenCalledWith(4, { shared: false, sharedWith: [7] })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows who a row is shared with and lists Shared with you as its own tab", async () => {
|
||||||
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||||
|
serverMode: true,
|
||||||
|
canShare: true,
|
||||||
|
installsEnabled: true,
|
||||||
|
canManage: false,
|
||||||
|
});
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
item({ id: 5, title: "mine", sharedWith: [{ id: 7, name: "Ann" }] }),
|
||||||
|
item({ id: 6, title: "for-me", mine: false, canChange: false, sharedWithMe: true }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
const badge = await screen.findByTestId("shared-people-5");
|
||||||
|
expect(badge).toHaveTextContent("Shared with 1 person");
|
||||||
|
expect(badge).toHaveAttribute("title", "Ann");
|
||||||
|
// A row shared with me by name is "Shared by others", not private.
|
||||||
|
expect(within(screen.getByTestId("artifact-6")).queryByText("Private")).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Shared with you" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.artifacts.list).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ scope: "withme" })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("views a page's copy here: an app runs in the sandbox, a PDF is drawn, an older page is copied first", async () => {
|
||||||
|
const page = (over: Partial<Artifact>) =>
|
||||||
|
item({ kind: "page", url: "https://claude.ai/artifact/p", ...over });
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
page({ id: 11, title: "app", files: [{ path: "app.html", size: 9 }] }),
|
||||||
|
page({ id: 12, title: "deck", files: [{ path: "deck-1.pdf", size: 9 }] }),
|
||||||
|
page({ id: 13, title: "old", files: [], source: "/w/old.html" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// The copy's own files come through the raw route.
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = vi.fn(async () => new Response("<p>app</p>")) as typeof fetch;
|
||||||
|
vi.mocked(api.artifacts.copy).mockResolvedValue({
|
||||||
|
item: page({ id: 13, title: "old", files: [{ path: "old.html", size: 3 }] }),
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await user.click(within(await screen.findByTestId("artifact-11")).getByTestId("artifact-view"));
|
||||||
|
expect(await screen.findByTestId("artifact-page-frame")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("artifact-preview")).toBeNull();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
await user.click(within(screen.getByTestId("artifact-12")).getByTestId("artifact-view"));
|
||||||
|
expect(await screen.findByTestId("pdf-view")).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
await user.click(within(screen.getByTestId("artifact-13")).getByTestId("artifact-view"));
|
||||||
|
await waitFor(() => expect(api.artifacts.copy).toHaveBeenCalledWith(13));
|
||||||
|
expect(await screen.findByTestId("artifact-page-frame")).toBeInTheDocument();
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps folders per account: make one, move an artifact in, filter by it", async () => {
|
||||||
|
localStorage.removeItem("agenthub.artifacts.folders");
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("deploy");
|
||||||
|
await user.click(screen.getByTestId("artifact-folder-new"));
|
||||||
|
await user.type(screen.getByLabelText("Folder name"), "Slides{Enter}");
|
||||||
|
// The new folder is picked at once, and empty.
|
||||||
|
expect(await screen.findByTestId("artifacts-empty-filtered")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "All folders" }));
|
||||||
|
await menuAction(user, "Move to folder…", screen.getByTestId("artifact-2"));
|
||||||
|
const dialog = await screen.findByTestId("move-dialog");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Slides" }));
|
||||||
|
const stored = JSON.parse(localStorage.getItem("agenthub.artifacts.folders")!);
|
||||||
|
expect(Object.values(stored.of)).toHaveLength(1);
|
||||||
|
expect(stored.of["2"]).toBe(stored.folders[0].id);
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Slides/ }));
|
||||||
|
expect(await screen.findByTestId("artifact-2")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("artifact-1")).toBeNull();
|
||||||
|
localStorage.removeItem("agenthub.artifacts.folders");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drags an artifact onto a folder to put it there, and onto All folders to take it out", async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"agenthub.artifacts.folders",
|
||||||
|
JSON.stringify({ folders: [{ id: "f1", name: "Slides" }], of: {} })
|
||||||
|
);
|
||||||
|
open();
|
||||||
|
const row = await screen.findByTestId("artifact-2");
|
||||||
|
expect(row).toHaveAttribute("draggable", "true");
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
const dataTransfer = {
|
||||||
|
setData: (k: string, v: string) => data.set(k, v),
|
||||||
|
getData: (k: string) => data.get(k) ?? "",
|
||||||
|
get types() {
|
||||||
|
return [...data.keys()];
|
||||||
|
},
|
||||||
|
effectAllowed: "",
|
||||||
|
dropEffect: "",
|
||||||
|
};
|
||||||
|
const stored = () => JSON.parse(localStorage.getItem("agenthub.artifacts.folders")!);
|
||||||
|
fireEvent.dragStart(row, { dataTransfer });
|
||||||
|
const chip = screen.getByTestId("artifact-folder-f1");
|
||||||
|
fireEvent.dragOver(chip, { dataTransfer });
|
||||||
|
expect(chip.className).toMatch(/ring-2/);
|
||||||
|
fireEvent.drop(chip, { dataTransfer });
|
||||||
|
expect(stored().of["2"]).toBe("f1");
|
||||||
|
expect(chip.className).not.toMatch(/ring-2/);
|
||||||
|
// Something that is not an artifact (a file from the desktop) is ignored.
|
||||||
|
fireEvent.drop(chip, {
|
||||||
|
dataTransfer: { ...dataTransfer, getData: () => "", types: ["Files"] },
|
||||||
|
});
|
||||||
|
expect(stored().of["2"]).toBe("f1");
|
||||||
|
fireEvent.dragStart(row, { dataTransfer });
|
||||||
|
fireEvent.drop(screen.getByRole("button", { name: "All folders" }), { dataTransfer });
|
||||||
|
expect(stored().of["2"]).toBeUndefined();
|
||||||
|
localStorage.removeItem("agenthub.artifacts.folders");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
|
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
|
||||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||||
serverMode: true,
|
serverMode: true,
|
||||||
@@ -404,7 +561,9 @@ describe("Artifacts", () => {
|
|||||||
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
||||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||||
await menuAction(user, "Remove from Hub", card);
|
await menuAction(user, "Remove from Hub", card);
|
||||||
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
|
await waitFor(() =>
|
||||||
|
expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false, sharedWith: [] })
|
||||||
|
);
|
||||||
const calls = vi.mocked(confirmDialog).mock.calls;
|
const calls = vi.mocked(confirmDialog).mock.calls;
|
||||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
api: {
|
api: {
|
||||||
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
||||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||||
|
// AI tools → Company hub (local: not enrolled; hub: nothing yet).
|
||||||
|
remoteClient: { status: vi.fn(async () => ({ enrolled: false })) },
|
||||||
|
remoteHub: {
|
||||||
|
audit: vi.fn(async () => ({ entries: [] })),
|
||||||
|
devices: vi.fn(async () => ({ devices: [] })),
|
||||||
|
sessions: vi.fn(async () => ({ sessions: [] })),
|
||||||
|
},
|
||||||
// AI tools → Profiles.
|
// AI tools → Profiles.
|
||||||
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
||||||
ccConfig: {
|
ccConfig: {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { AuthState, ServerUserUsage } from "../../lib/api";
|
|||||||
let authState: AuthState = { mode: "local" };
|
let authState: AuthState = { mode: "local" };
|
||||||
vi.mock("../../components/AuthGate", () => ({
|
vi.mock("../../components/AuthGate", () => ({
|
||||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||||
|
usePermissions: () => ({ isAdmin: true, local: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const usage = { cost: 0, tokens: 0 };
|
const usage = { cost: 0, tokens: 0 };
|
||||||
@@ -56,6 +57,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
api: {
|
api: {
|
||||||
users,
|
users,
|
||||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||||
|
remoteHub: {
|
||||||
|
devices: vi.fn(async () => ({ devices: [] })),
|
||||||
|
sessions: vi.fn(async () => ({ sessions: [] })),
|
||||||
|
},
|
||||||
accountPool: {
|
accountPool: {
|
||||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||||
profiles: vi.fn(async () => ({ profiles: [] })),
|
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.66 (rebuild client to match)
|
UI build v2.8.72 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+21
-33
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
|||||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||||
|
|
||||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||||
|
|
||||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||||
|
|
||||||
@@ -197,9 +197,11 @@ POST /api/lanes/ensure
|
|||||||
```
|
```
|
||||||
|
|
||||||
`cwd` is optional — when missing or empty the server falls back to
|
`cwd` is optional — when missing or empty the server falls back to
|
||||||
`os.homedir()` (the user's home directory). This is how the Sagi mascot
|
`os.homedir()` (the user's home directory) — or, for a server-mode member
|
||||||
adopts its dedicated workspace without the client needing to know an absolute
|
with an own folder, `<their folder>/sagi`, made as them on first use (the
|
||||||
path. Existing cwd values still pass through unchanged. `pipeline` is
|
host's home lies outside their folder and would be `403 EFOLDER`). This is
|
||||||
|
how the Sagi mascot adopts its dedicated workspace without the client
|
||||||
|
needing to know an absolute path. Existing cwd values still pass through unchanged. `pipeline` is
|
||||||
optional and, like `title`, applies **only when a workspace is actually created**
|
optional and, like `title`, applies **only when a workspace is actually created**
|
||||||
— an existing workspace is returned untouched. Omitted, the workspace gets `default`.
|
— an existing workspace is returned untouched. Omitted, the workspace gets `default`.
|
||||||
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
|
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
|
||||||
@@ -1364,8 +1366,10 @@ cannot collide on `index.lock`.
|
|||||||
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
|
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
|
||||||
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
|
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
|
||||||
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
|
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
|
||||||
through the same `git status` check as `discard`. `unshelve` refuses a
|
through the same `git status` check as `discard`. `unshelve` refuses
|
||||||
dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean
|
anything staged and any change in one of the shelf's own files
|
||||||
|
(`400 EDIRTY`, those files in `paths`) so a restore never mixes with new
|
||||||
|
work — changes in other files (the ones a partial `shelve` left) stay put; a clean
|
||||||
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
|
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
|
||||||
answers `conflicts: [...]`, `kept: true` — the files are resolved with
|
answers `conflicts: [...]`, `kept: true` — the files are resolved with
|
||||||
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
|
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
|
||||||
@@ -3491,33 +3495,12 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
|
|||||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||||
```
|
```
|
||||||
|
|
||||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's chat window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
The mascot (Sagi) no longer chats (v2.8.68): a click opens its window — its name, a pat and the status of your sessions. It can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||||
|
|
||||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||||
|
|
||||||
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
||||||
|
|
||||||
### Chat between users (server mode)
|
|
||||||
|
|
||||||
The pet's window has a rail of chats beside the chat with the pet: direct chats (one per pair of users) and groups. Only a room's MEMBERS read or write it — an admin or analyst included; another user's room answers `404 ENOTFOUND` like a missing one. Local mode answers `400 ELOCALMODE`. Code: `server/lib/chat.js`, `server/routes/chat.js`, client `client/src/components/Sagi/ChatHub.tsx` + `useChat.ts`.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/chat/people {people: [{id, username, displayName, avatarAt, disabled}]} — every account but you; a disabled one is named in old chats, never offered for a new one
|
|
||||||
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, clearedId, reads (every member's read mark — "Seen"), unread
|
|
||||||
POST /api/chat/rooms {kind: "dm", userId} → the pair's one direct chat (201 new / 200 existing); {kind: "group", name?, userIds} → 201
|
|
||||||
PATCH /api/chat/rooms/:id {name?, add?, remove?} — the group's creator only (403 EFORBIDDEN)
|
|
||||||
DELETE /api/chat/rooms/:id Disband a group — its creator only, for every member, files included
|
|
||||||
POST /api/chat/rooms/:id/clear Delete the chat for YOURSELF: {hidden, purged} — see below
|
|
||||||
POST /api/chat/rooms/:id/leave Leave a group; the last one out deletes it and its files
|
|
||||||
GET /api/chat/rooms/:id/messages?before=&limit= {messages (oldest first), hasMore} — 50 by default, ≤ 200
|
|
||||||
POST /api/chat/rooms/:id/messages JSON {body} or multipart `body` + ≤ 10 `files` of ≤ 25 MB; body ≤ 8000 chars
|
|
||||||
DELETE /api/chat/rooms/:id/messages/:messageId Delete your OWN text message and its files (403 EFORBIDDEN otherwise, an admin included)
|
|
||||||
POST /api/chat/rooms/:id/read {upTo} — read mark, never moves back nor past the newest message; a move is sent as `chat_read`
|
|
||||||
GET /api/chat/rooms/:id/files/:fileId The file, members only, `sandbox` CSP + `nosniff`; PNG/JPEG/GIF/WebP inline, else attachment
|
|
||||||
```
|
|
||||||
|
|
||||||
Files are stored under `<data dir>/chat/<room id>/<32 hex id>` — a name the server makes. Right-click menus in the window: a room (open, mark read, mute notifications — per browser —, copy name, group settings, leave), the rail's pet and **+** (mark all read), a message (copy text, reply, message the sender privately, delete your own), a file (open, download, copy name) and a group member (message privately, remove — the creator). **Deleting a chat** is per person: the messages so far vanish for the caller only (`chat_members.cleared_id`) and a direct chat leaves their list (`hidden`) until a new message arrives; the others keep theirs. What EVERY member has deleted is deleted for real — the messages up to the lowest `cleared_id` and their files on disk, and a direct chat both people deleted, whole (`purge`, also run when a member leaves or is removed). A group whose creator left or was deleted passes to its longest-standing member. Group changes (`system` lines) are sent live as `chat_message` and never count as unread. An upload is written to `<data dir>/chat/.incoming/` and moved into place — never held in memory whole. In the browser, only the first tab to see a message plays its sound or shows a system notification; a message from someone in a room not on screen shows as a card on the pet. With the pet hidden, a 💬 button in the bottom-right corner (`ChatDock.tsx`) opens the same chat window. **Seen**: the room shows under your newest message "Seen" (direct chat), "Seen by …" or "Seen by everyone" (group), from `reads`. A group change adds a `system` message (`created`, `renamed`, `left`, `added:<ids>`, `removed:<ids>`). Every change is sent over `/ws` as `chat_message` (`{roomId, message, to}`), `chat_room` (`{roomId, to}`) or `chat_deleted` (`{roomId, messageId, to}`) or `chat_read` (`{roomId, userId, lastReadId, to}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role.
|
|
||||||
|
|
||||||
### Session snapshots
|
### Session snapshots
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -3533,8 +3516,8 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
|||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
|
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
|
||||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, exportsEnabled, canManage}
|
||||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub (installs: server mode only)
|
||||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||||
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
|
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
|
||||||
POST /api/artifacts/page {url, title?, description?}
|
POST /api/artifacts/page {url, title?, description?}
|
||||||
@@ -3546,11 +3529,13 @@ POST /api/artifacts/skill/new {name, description, content, titl
|
|||||||
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
|
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
|
||||||
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
|
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
|
||||||
GET /api/artifacts/:id {item}
|
GET /api/artifacts/:id {item}
|
||||||
PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?, skill?: {name, description, content}} (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
|
GET /api/artifacts/people → {people: [{id, username, name}]} — server mode: every enabled account but the caller, names only (who an artifact can be shared with)
|
||||||
|
PATCH /api/artifacts/:id {title?, description?, shared?, sharedWith?: [userId], source?, market?, skill?: {name, description, content}} (sharedWith: server mode, replaces who it is shared with by name — up to 100 enabled users, the owner dropped; a row out of its owner's library shared with nobody is deleted, `removed`) (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
|
||||||
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
|
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
|
||||||
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
||||||
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
||||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||||
|
POST /api/artifacts/:id/copy → {item} — a page captured before pages kept a copy: snapshot the HTML file it was published from, if still in its workspace (once per row per server run; any viewer, who gets only the copy)
|
||||||
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
|
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
|
||||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
|
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
|
||||||
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||||
@@ -3573,7 +3558,10 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
|
|||||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list.
|
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list.
|
||||||
- **a skill follows its source** — a skill row taken from a folder (its owner's `~/.claude/skills/<name>`, or the workspace's `.claude/skills/<name>`; the host's in local mode) is brought up to that folder whenever the Hub is listed (`syncSkillSources`, at most every 15 s; only a folder whose file names, sizes or mtimes changed is read again, through the same checks as a pick — no symlink, no secret, ≤ 5 MB — a member's files as theirs). A change is a NEW snapshot directory, then the old one dropped, and an `artifact_changed` broadcast. `artifacts.version` is a snapshot's content hash and `source_hash` the folder's when last taken: an upload or a Hub edit records the folder as it is then, so only a LATER change to the folder replaces it — the newer change wins. A list row's `updateAvailable` is true when the skill is `installed` and the viewer's `~/.claude/skills/<name>` (any account they run as) differs from `version`; the page then shows **Update**. `install` with `update: true` (a skill, `ENOTINSTALLED` when it is not installed) replaces those copies like `replace: true` — the old ones moved aside — and is not counted as another install. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
- **a skill follows its source** — a skill row taken from a folder (its owner's `~/.claude/skills/<name>`, or the workspace's `.claude/skills/<name>`; the host's in local mode) is brought up to that folder whenever the Hub is listed (`syncSkillSources`, at most every 15 s; only a folder whose file names, sizes or mtimes changed is read again, through the same checks as a pick — no symlink, no secret, ≤ 5 MB — a member's files as theirs). A change is a NEW snapshot directory, then the old one dropped, and an `artifact_changed` broadcast. `artifacts.version` is a snapshot's content hash and `source_hash` the folder's when last taken: an upload or a Hub edit records the folder as it is then, so only a LATER change to the folder replaces it — the newer change wins. A list row's `updateAvailable` is true when the skill is `installed` and the viewer's `~/.claude/skills/<name>` (any account they run as) differs from `version`; the page then shows **Update**. `install` with `update: true` (a skill, `ENOTINSTALLED` when it is not installed) replaces those copies like `replace: true` — the old ones moved aside — and is not counted as another install. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||||
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
|
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared, recipients}`) reaches everyone for a shared row, else its owner, the people it is shared with by name (`recipients`) and staff.
|
||||||
|
- **Shared with chosen people** (`artifact_shares`, one row per person): `shared = 1` stays "everyone" (the Hub); a row may instead be shared with named users only. They see it (`canSee`), may install it, and find it under `?scope=withme` (**Shared with you**); `sharedWith` (the list) is shown only to the owner or an admin, `sharedWithMe` tells a recipient. Share again never widens a row shared by name to everyone. Changes are audited (`shared with ann, bob`).
|
||||||
|
- **A page's own copy** (a claude.ai page is private to the account that published it): a publish from an HTML file inside the session's workspace snapshots that file plus the supporting files the tool named (`files`), under the pinned-file guards (read as the folder's account, no secret names, 25 MB in all), and a republish of the same link replaces it. A Claude Docs doc or a typed artifact (Slides, Design…) has no such file: `scripts/artifact-export-hint.js` (a synchronous PostToolUse hook, AgentHub panes only, off when the pane carries `AGENTHUB_ARTIFACT_EXPORT=0` — the admin's `exportsEnabled` switch) asks Claude to save `artifacts-export/<artifact id>.(md|pdf|html)` in the workspace root, and the Stop capture attaches that file to the page's row (no second card) and git-excludes the folder. The page then shows **View**: an HTML copy runs in `HtmlFrame`'s sandbox (scripts, never the dashboard's origin), Markdown in `MarkdownPreview`, a PDF drawn by pdf.js (`client/src/components/PdfView.tsx`, legacy build, loaded only when a PDF is opened). The claude.ai link stays under ⋯ → Open.
|
||||||
|
- **Folders** are the viewer's own arrangement, not an artifact fact: the account preference `agenthub.artifacts.folders` (`/api/me/prefs`, 64 KB; `client/src/lib/artifactFolders.ts`), never seen by anyone else; deleting a folder only takes its artifacts out of it.
|
||||||
|
|
||||||
### Query Explorer
|
### Query Explorer
|
||||||
|
|
||||||
@@ -3670,7 +3658,7 @@ POST /api/updates/check
|
|||||||
POST /api/updates/apply
|
POST /api/updates/apply
|
||||||
```
|
```
|
||||||
|
|
||||||
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
|
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only` (first putting back `package-lock.json` / `client/package-lock.json` when they are the ONLY tracked changes — an earlier update's `npm install` rewrote them; any other change still makes the merge refuse), rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
|
||||||
|
|
||||||
### Import history
|
### Import history
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -531,7 +531,7 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
|||||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||||
| **Branch** → **Switch branch** | `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
| **Branch** → **Switch branch** | the list is a combobox: it opens with a **Search branches…** box focused, typing narrows the local and remote branches to names containing the text, ↑/↓ move, Enter picks, Esc closes (#88 — every `Select` longer than 8 options gets the same search box); `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||||
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
||||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||||
@@ -552,7 +552,9 @@ you made in a terminal is never shown or touched. **Shelve (n)** beside
|
|||||||
ones included) with an optional one-line note; **Shelve and switch** does the
|
ones included) with an optional one-line note; **Shelve and switch** does the
|
||||||
same for every change before a switch, and puts the shelf straight back if the
|
same for every change before a switch, and puts the shelf straight back if the
|
||||||
switch then fails. The **Shelves (n)** list above the files shows each shelf's
|
switch then fails. The **Shelves (n)** list above the files shows each shelf's
|
||||||
branch, note, time and files, with **Restore** (on a clean tree only; a clean
|
branch, note, time and files, with **Restore** (refused while anything is staged or one of the shelf's own files
|
||||||
|
has changed since — the files a partial Shelve (n) left behind do not block
|
||||||
|
it; a clean
|
||||||
restore deletes the shelf, a conflicted one keeps it and lists the files under
|
restore deletes the shelf, a conflicted one keeps it and lists the files under
|
||||||
**Merge conflicts** — *Keep mine* is the branch, *Take theirs* the shelf — and
|
**Merge conflicts** — *Keep mine* is the branch, *Take theirs* the shelf — and
|
||||||
you delete the shelf yourself once they are settled) and **Delete** (two
|
you delete the shelf yourself once they are settled) and **Delete** (two
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ checkout unnecessary.
|
|||||||
|
|
||||||
| Component | Where it comes from |
|
| Component | Where it comes from |
|
||||||
|---|---|
|
|---|---|
|
||||||
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` |
|
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` — plus one synchronous `PostToolUse` entry (matcher `Artifact\|mcp__claude_ai_.*__batch`) running `scripts/artifact-export-hint.js`, which asks Claude for a viewable copy of a Doc / Slides / Design it just published (see docs/API.md § Artifacts hub) |
|
||||||
| The dashboard server | started detached by the bootstrap, from the plugin cache |
|
| The dashboard server | started detached by the bootstrap, from the plugin cache |
|
||||||
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
|
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
|
||||||
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
|
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
|
||||||
|
|||||||
+91
-1
@@ -534,6 +534,48 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
|||||||
> launch file instead of tmux `-e`), but no other account's process or file.
|
> launch file instead of tmux `-e`), but no other account's process or file.
|
||||||
> The AI Proxy gateway does not cover pool tokens.
|
> The AI Proxy gateway does not cover pool tokens.
|
||||||
|
|
||||||
|
## Users' own machines (Company account)
|
||||||
|
|
||||||
|
A user with their own computer can run **AgentHub local** there and still use
|
||||||
|
the Claude account pool. The pool's logins never leave this server: their
|
||||||
|
sessions go through the server's gateway, which adds the account. Only those
|
||||||
|
sessions are reported back here — their conversation and token usage, once an
|
||||||
|
hour and when the session ends. A session on the user's own login sends
|
||||||
|
nothing, and the server never reads or runs anything on their machine.
|
||||||
|
Design and limits: `docs/plans/remote-clients/README.md`.
|
||||||
|
|
||||||
|
On the user's machine (company LAN only, plain HTTP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agenthub remote login http://10.1.13.7:4820 --user mia --name mia-laptop # hub password, asked hidden
|
||||||
|
agenthub remote status # hub, device, what was sent and when
|
||||||
|
agenthub remote upload # send now instead of waiting for the hour
|
||||||
|
agenthub remote logout # the token stops working on the server at once
|
||||||
|
```
|
||||||
|
|
||||||
|
or Settings → AI tools → **Company hub**. The machine then gets **Company
|
||||||
|
account** in the AI Proxy menu of a new Claude session; that choice is kept on
|
||||||
|
a resume. The device token lasts 90 days; after it expires or is revoked, log
|
||||||
|
in again and resume any session that was open (a running pane keeps the token
|
||||||
|
it started with).
|
||||||
|
|
||||||
|
On the server: Settings → AI tools → Company hub lists the user's own
|
||||||
|
machines (Revoke, their password again) and who opened their sessions; an
|
||||||
|
admin sees and revokes any user's machines on the **Users** page. Their
|
||||||
|
sessions are listed device › folder, with what the gateway counted; one used
|
||||||
|
with the device token outside AgentHub is flagged **unmonitored**, and an
|
||||||
|
alert rule of type **Company account outside AgentHub** (Settings → Alerts)
|
||||||
|
fires for it once it has stayed unregistered for a minute.
|
||||||
|
|
||||||
|
| Variable (server) | Default | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `AGENTHUB_REMOTE_GATE` | `warn` | `block` refuses a session AgentHub did not register (403) |
|
||||||
|
| `AGENTHUB_REMOTE_RETAIN_DAYS` | `30` | conversations older than this are deleted; token rows stay |
|
||||||
|
| `AGENTHUB_DEVICE_TOKEN_DAYS` | `90` | device token lifetime |
|
||||||
|
| `AGENTHUB_REMOTE_UPSTREAM` | `https://api.anthropic.com` | where the gateway relays |
|
||||||
|
| `AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` | `60000` | how long a session may stay unregistered before the alert fires |
|
||||||
|
| `AGENTHUB_REMOTE_FLUSH_MS` | `120000` | an upload round that stopped half-way is imported this long after its last chunk |
|
||||||
|
|
||||||
## Codex account pool
|
## Codex account pool
|
||||||
|
|
||||||
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
|
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
|
||||||
@@ -1054,7 +1096,9 @@ a pane really is. Each case below is refused or bounded, with a test in
|
|||||||
running host server uses that server's own binary (`/proc/<pid>/exe`, found
|
running host server uses that server's own binary (`/proc/<pid>/exe`, found
|
||||||
from its socket in `/proc`), so installing a newer tmux under a running
|
from its socket in `/proc`), so installing a newer tmux under a running
|
||||||
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
|
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
|
||||||
100% CPU.
|
100% CPU. That binary is cached for 10 s; once its server has exited (the
|
||||||
|
last pane stopped) the call falls back to PATH's `tmux` instead of failing
|
||||||
|
with `ENOENT`.
|
||||||
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
|
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
|
||||||
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
|
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
|
||||||
the call with the binary of the member's running server (`/proc/<pid>/exe`,
|
the call with the binary of the member's running server (`/proc/<pid>/exe`,
|
||||||
@@ -1156,6 +1200,52 @@ The same re-run installs `agenthub-provision delete`, behind the Users page's
|
|||||||
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
|
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
|
||||||
action: delete") and changes nothing.
|
action: delete") and changes nothing.
|
||||||
|
|
||||||
|
The same re-run (v2.8.68) keeps members from freezing the box — on 11-02
|
||||||
|
some 36 open sessions plus test runs filled RAM and the 4 GB swap on a slow
|
||||||
|
disk, and only a hard reset brought it back. The box is for writing code;
|
||||||
|
tests run on each member's own machine:
|
||||||
|
|
||||||
|
- **Memory.** `agenthub.service` gets `MemoryMax=85%`, `MemorySwapMax=1G`,
|
||||||
|
`OOMPolicy=continue` and `OOMScoreAdjust=-100`. The cgroup holds the server
|
||||||
|
AND every member pane, so memory runs out inside the unit first and the
|
||||||
|
kernel kills one process there by `oom_score_adj`: every command a member's
|
||||||
|
Claude runs is 1000 (`CLAUDE_CODE_SHELL_PREFIX=/opt/agenthub/libexec/tool-run`,
|
||||||
|
which also tells Claude why its command died), a Terminal tab 800, an AI
|
||||||
|
session 300, the dashboard and tmux -100. A test run dies; the session
|
||||||
|
holding the conversation does not. Codex runs its commands without that
|
||||||
|
wrapper, so its commands sit at 300 beside it.
|
||||||
|
- **CPU and disk.** `pane-launch` starts every member pane at `nice 10` and
|
||||||
|
best-effort IO priority 7 (the nice cannot be taken back without root), and
|
||||||
|
defaults `VITEST_MAX_THREADS` / `VITEST_MAX_FORKS` to 2.
|
||||||
|
- **No tests or containers from Claude.** A synchronous `PreToolUse` hook
|
||||||
|
(`/opt/agenthub/app/scripts/member-guard.js`, Bash only, `ah-*` accounts
|
||||||
|
only) denies `vitest`, `jest`, `phpunit`, `pest`, `pytest`, `npm test`,
|
||||||
|
`npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`,
|
||||||
|
`podman`, … and tells Claude to leave testing to the member's machine.
|
||||||
|
A person typing a command in a Terminal tab, and Codex, are not covered.
|
||||||
|
An admin can allow them again with **Settings → AI tools → Tests on this
|
||||||
|
server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in
|
||||||
|
`/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect
|
||||||
|
at members' next Bash call, no restart.
|
||||||
|
- **At most 4 AI sessions per member at once** (`MAX_AI_SESSIONS=<n>` in
|
||||||
|
`/etc/agenthub/server.conf`, `0` = no limit). Counted from the account's
|
||||||
|
running processes (`server/lib/ai-procs.js`: `claude`, `codex`, `agy`,
|
||||||
|
`cursor-agent`, `omp`, a nested one counted once), so a `claude` typed into
|
||||||
|
a Terminal tab counts and the tab itself does not. A new pane past the
|
||||||
|
limit answers `429 ESESSIONLIMIT`; a session started by hand past it has
|
||||||
|
every prompt refused by the guard's `UserPromptSubmit` until another one
|
||||||
|
ends. The host account (admins) is not limited.
|
||||||
|
|
||||||
|
The memory caps apply to the running unit at once (the re-run does
|
||||||
|
`daemon-reload`); `OOMScoreAdjust`, the session limit on new panes and the
|
||||||
|
guard script in `/opt/agenthub/app` arrive with the next restart
|
||||||
|
(`sudo -n /usr/bin/systemctl restart --no-block agenthub.service` — panes
|
||||||
|
survive, `KillMode=process`). Panes started before the re-run keep their old
|
||||||
|
priority. Check with
|
||||||
|
`systemctl show agenthub.service -p MemoryMax,MemorySwapMax,OOMPolicy`
|
||||||
|
and, in a new member pane, `ps -o ni= -p $$; cat /proc/self/oom_score_adj;
|
||||||
|
echo $CLAUDE_CODE_SHELL_PREFIX`.
|
||||||
|
|
||||||
### Deleting a user
|
### Deleting a user
|
||||||
|
|
||||||
Users page → Edit → **Delete user** (the admin's own password, then a red
|
Users page → Edit → **Delete user** (the admin's own password, then a red
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
|||||||
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
||||||
|
|
||||||
- **Local mode:** one personal library, no sharing UI.
|
- **Local mode:** one personal library, no sharing UI.
|
||||||
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it; a shared artifact appears in the **Hub** for every signed-in user. A shared skill or plugin can be **installed** into the viewer's own account.
|
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file, or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account.
|
||||||
|
|
||||||
## Rules (binding, same spirit as CLAUDE.md)
|
## Rules (binding, same spirit as CLAUDE.md)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
|||||||
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a type that sandbox cannot show (Office files, zip, PDF — the sandbox blocks both a download and the PDF viewer) gets Download only, never a blank Preview. A `page` is only a link out, accepted only as `https://claude.ai/…`.
|
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a type that sandbox cannot show (Office files, zip, PDF — the sandbox blocks both a download and the PDF viewer) gets Download only, never a blank Preview. A `page` is only a link out, accepted only as `https://claude.ai/…`.
|
||||||
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
|
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
|
||||||
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
|
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
|
||||||
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
|
- **Visibility follows ownership:** a developer sees their own artifacts plus those shared with everyone or with them by name; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
|
|
||||||
|
|||||||
+186
-140
@@ -1,175 +1,221 @@
|
|||||||
# Remote clients — work runs on each user's machine, the hub only manages
|
# Remote clients — work runs on each user's machine, the hub only records company spend
|
||||||
|
|
||||||
Status: **PLAN, not built.** Decided 2026-10-09. Branch `server-s3` (one Linux
|
Status: **B1–B3 built 2026-11-04 for the Claude pool** (§ Built); B4 and
|
||||||
user per account on the hub) stays as it is until this plan is proven or dropped.
|
Codex not started. First decided 2026-10-09, re-scoped 2026-11-04 (this
|
||||||
|
version). Server mode with S3 stays and runs side by side: a user with a
|
||||||
|
weak machine keeps working on the hub, a user with their own machine runs
|
||||||
|
AgentHub local and enrolls it.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Everyone logging into one machine (server mode, S3) does not scale past ~10
|
Everyone logging into one machine (server mode, S3) does not scale past ~10
|
||||||
concurrent users: every pane, build and test runs on the hub. Here the compute
|
concurrent users: every pane, build and test runs on the hub. Here the compute
|
||||||
moves to each user's machine; the hub keeps identity, the shared accounts, and
|
moves to each user's machine; the hub keeps identity, the shared accounts, and
|
||||||
every workspace/session/conversation/token record.
|
the record of what the COMPANY's accounts were used for.
|
||||||
|
|
||||||
## Decisions (user, 2026-10-09)
|
## Decisions (user, 2026-11-04 — replaces the 10-09 list)
|
||||||
|
|
||||||
1. Each user installs **AgentHub local** on their own machine.
|
1. Each user may install **AgentHub local** on their own machine and enroll it
|
||||||
2. The **full conversation** is uploaded to the hub — only for sessions started
|
with the hub once. **S3 stays** alongside it.
|
||||||
through AgentHub.
|
2. **The account decides, not the workspace.** Only a session that runs on a
|
||||||
3. Shared accounts (the Claude pool, shared AI Proxies) are usable **only through
|
company account — the hub's pool or a shared AI Proxy, i.e. one that goes
|
||||||
AgentHub**. The company has at least as many seats as people; a seat may be
|
through the hub's gateway — is reported. A session on the user's OWN login
|
||||||
used by more than one person.
|
or own proxy sends **nothing**: no conversation, no tokens, no heartbeat,
|
||||||
4. Use without AgentHub is **detected and warned about**, and **blocked when an
|
not even the workspace's name. Neither does anything run outside AgentHub.
|
||||||
admin turns blocking on**.
|
3. What a reported session sends: its **conversation and token usage**, never
|
||||||
5. The hub is reached over the company LAN at `http://10.1.13.7:4820` — **no
|
the repository. The hub **never reads the client's source tree, never runs
|
||||||
HTTPS**.
|
anything on the client, never drives it** (no terminal, no start/stop, no
|
||||||
6. An admin sees, in one click from the Users page, which machines a user has,
|
file access). The client pushes; the hub never connects to a client.
|
||||||
which workspaces they used, and what each session did.
|
4. Cadence: **one registration** when a company-account session starts
|
||||||
7. `server-s3` is kept until this architecture is settled.
|
(session id + device, no content — what the gateway checks), then
|
||||||
|
conversation and tokens **once an hour** and once more when the session
|
||||||
|
ends. No per-minute heartbeat (user, 11-04). A queue on disk carries what
|
||||||
|
an offline machine could not send; it is sent back in the office.
|
||||||
|
The conversation is uploaded **whole** (tool results and code included —
|
||||||
|
user, 11-04: no stripping); only strings that look like keys, tokens or
|
||||||
|
passwords are redacted before sending.
|
||||||
|
The hub keeps a conversation **30 days**; token and cost rows are kept.
|
||||||
|
5. The company account's credential **never leaves the hub**. A client holds a
|
||||||
|
device token only; its Claude calls go through the hub's gateway, which adds
|
||||||
|
the real credential. Revoking the device token ends access at once.
|
||||||
|
6. Use of a device token outside AgentHub is **detected**; `remote_gate`
|
||||||
|
decides `warn` (default: forward, mark the session unmonitored, alert) or
|
||||||
|
`block` (403).
|
||||||
|
7. The hub is reached over the company LAN only (`10.1.13.7`); a device token
|
||||||
|
**expires after 90 days** (`agenthub remote login` again). At home a
|
||||||
|
company-account session simply cannot reach the hub — AgentHub local says
|
||||||
|
so plainly — while the user's own login works as usual. Working from home
|
||||||
|
later means a VPN, never exposing the hub. **No HTTPS** (user, 11-04): plain HTTP
|
||||||
|
on the company LAN, accepting that someone already inside the network
|
||||||
|
could sniff conversations, hub passwords and device tokens. Adding it
|
||||||
|
later is a reverse proxy in front of the hub (docs/SERVER-MODE.md § Put
|
||||||
|
HTTPS in front of it), no code change.
|
||||||
|
8. Company vs own is decided by the **account picked at session start**: an
|
||||||
|
enrolled machine's account menu gains "Company account", whose pane talks
|
||||||
|
to the hub's gateway; the default login and the user's own AI Proxies are
|
||||||
|
their own. It is recorded on the run (`dashboard_runs.proxy`), shown as a
|
||||||
|
"Company" badge, and a resume keeps it. A company account is usable only
|
||||||
|
through the gateway, so neither side can be mislabelled.
|
||||||
|
9. Each user can see, on their local AgentHub, what was sent for each
|
||||||
|
session and when, and on the hub who (admin/analyst) opened their
|
||||||
|
sessions (`audit_log`).
|
||||||
|
10. On the hub a client's sessions are shown grouped **device › folder** — no
|
||||||
|
lane rows are created for them.
|
||||||
|
|
||||||
## Shape
|
## Shape
|
||||||
|
|
||||||
```
|
```
|
||||||
user machine — AgentHub LOCAL hub 10.1.13.7 — AgentHub SERVER mode
|
user machine — AgentHub LOCAL hub 10.1.13.7 — SERVER mode
|
||||||
┌───────────────────────────────────┐ ┌──────────────────────────────────┐
|
┌──────────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||||
│ pane: claude │─hooks +▶│ /api/remote/ingest (device token)│
|
│ pane on a COMPANY account: │─/v1/… ─▶│ gateway: device token + session │
|
||||||
│ ANTHROPIC_BASE_URL = hub gateway│ transcr.│ → sessions/events/tokens, owned │
|
│ ANTHROPIC_BASE_URL = hub gateway │ │ check → pool credential added │
|
||||||
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ by the device's user │
|
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ → Anthropic; usage counted now │
|
||||||
│ │─/v1/… ─▶│ /api/proxy-gw → pool token added │
|
│ │─start ─▶│ session registered (id only) │
|
||||||
└───────────────────────────────────┘ └──────────────────────────────────┘
|
│ │─hourly▶│ conversation + tokens │
|
||||||
|
│ pane on the user's OWN login │ (none) │ │
|
||||||
|
└──────────────────────────────────────┘ └──────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
No pool token or proxy key ever reaches a user's machine; it holds one device
|
## What "conversation, not source" can and cannot mean
|
||||||
token. Plain HTTP means anyone sniffing the LAN can read that token and the
|
|
||||||
conversations — accepted for an internal network. Moving to HTTPS later is a
|
|
||||||
reverse proxy in front of the hub (Caddy `tls internal`), no code change.
|
|
||||||
|
|
||||||
## What the hub can see (per tool, checked 2026-10-09)
|
The hub stores no file of the repository. But a conversation is made of what
|
||||||
|
the agent read and wrote: a transcript's tool results hold the files Claude
|
||||||
|
`Read`, its `Edit`/`Write` inputs hold code, and every request through the
|
||||||
|
gateway carries the whole context (code included) on its way to Anthropic.
|
||||||
|
So "conversation only" still contains source excerpts. Decided (user,
|
||||||
|
11-04): they are NOT stripped — the transcript goes whole, only secret-shaped
|
||||||
|
strings are redacted.
|
||||||
|
|
||||||
Uploads cover every session the machine's AgentHub started, in ANY of its
|
## Gateway checks (verified 2026-10-09, Claude Code 2.1.295)
|
||||||
workspaces — company repo or the user's own project alike. A session run
|
|
||||||
outside AgentHub on the user's own login is invisible; only pool use is gated.
|
|
||||||
|
|
||||||
| Tool | Conversation | Tokens / cost | Pool / gate | Reader today |
|
Every `/v1/messages` call carries `x-claude-code-session-id: <id>`, and
|
||||||
|---|---|---|---|---|
|
`--session-id <uuid>` sets it — `spawnRun` already starts Claude panes that
|
||||||
| Claude Code | yes | yes | yes (R3) | hooks + transcript |
|
way. The gateway checks each request:
|
||||||
| Codex | yes | yes | not yet (Codex pool = later) | `codex-usage.js` rollouts |
|
|
||||||
| Antigravity | no — needs a renderer for `transcript_full.jsonl` | yes | no (own login) | `antigravity-usage.js` |
|
|
||||||
| Oh My Pi, other catalog tools | no | no | no | none: only the pane run (workspace, time) |
|
|
||||||
| DeepSeek CLI / not in catalog | no | no | no | not launchable as a tool; a shell pane records nothing |
|
|
||||||
|
|
||||||
R2 therefore uploads Claude transcripts, Codex rollouts and agy conversation
|
|
||||||
files (`remote-sync.js` today mirrors only the first two). Any tool beyond that
|
|
||||||
needs its own reader first — one per tool, on demand.
|
|
||||||
|
|
||||||
## Gating
|
|
||||||
|
|
||||||
Verified 2026-10-09 against Claude Code 2.1.295 with a capture server: every
|
|
||||||
`/v1/messages` call carries `x-claude-code-session-id: <id>` (same id in
|
|
||||||
`metadata.user_id`), and `--session-id <uuid>` sets it — `spawnRun` already
|
|
||||||
starts Claude panes that way.
|
|
||||||
|
|
||||||
The gateway checks each request:
|
|
||||||
- bearer = a device token that is not revoked, of a user who is not disabled —
|
- bearer = a device token that is not revoked, of a user who is not disabled —
|
||||||
else **always 401**;
|
else **always 401**;
|
||||||
- `x-claude-code-session-id` = a session the hub received uploads for, from
|
- `x-claude-code-session-id` = a session that SAME device registered at its
|
||||||
THAT device, within `REMOTE_LIVE_SEC` (300 s; 60 s grace for a new session).
|
start. The registration, not the hourly upload, is what this check reads.
|
||||||
|
|
||||||
A request failing the second check is a session AgentHub is not uploading
|
Relay spike (2026-10-09, script `~/.cache/agenthub-spike/relay.js`): a client
|
||||||
(token copied out of AgentHub, or uploads switched off). Setting
|
with no login and a fake `ANTHROPIC_AUTH_TOKEN` worked through a relay that
|
||||||
`remote_gate` decides: `warn` (default) forwards it and records the session
|
swapped in a team subscription's OAuth token (+ `anthropic-beta:
|
||||||
as **unmonitored** with its token count from the response's `usage`, and
|
oauth-2025-04-20`): answers, `--resume`, local tools, prompt-cache reads,
|
||||||
fires an alert (existing `fireAlert`); `block` refuses it with 403. Ceiling:
|
rate-limit headers. Rules it gave:
|
||||||
someone who also fakes the upload stream passes — detection, not proof.
|
- set `ENABLE_PROMPT_CACHING_1H=1` in the pane (a base URL drops to 5-minute
|
||||||
|
cache entries otherwise);
|
||||||
Relay spike (2026-10-09, Claude Code 2.1.295, script kept in
|
- keep a session on ONE pool account (the cache is per account);
|
||||||
`~/.cache/agenthub-spike/relay.js`): a client with NO login and
|
- give the gateway through `--settings=<file>` (a repo's `.claude/settings*.json`
|
||||||
`ANTHROPIC_AUTH_TOKEN=<fake>` worked through a relay that swapped in a team
|
`env` overrides the pane env), as AI Proxy panes already do.
|
||||||
subscription's OAuth token (+ `anthropic-beta: oauth-2025-04-20`). Checked:
|
Not checked: `/usage`, claude.ai MCP connectors, Remote Control — likely lost
|
||||||
answers, `--resume`, a local Bash tool call, prompt cache reads on every turn
|
through a relay.
|
||||||
(18–26k tokens), `anthropic-ratelimit-unified-*` headers passed back. Three
|
|
||||||
findings for R3:
|
|
||||||
- Through a base URL Claude Code writes **5-minute** cache entries; set
|
|
||||||
`ENABLE_PROMPT_CACHING_1H=1` in the pane (verified: 1h entries) to keep the
|
|
||||||
subscription's 1-hour TTL.
|
|
||||||
- The cache is per ACCOUNT (a fresh client read 18k cached tokens on its first
|
|
||||||
call), so the gateway must keep a session on ONE pool account — switching
|
|
||||||
accounts mid-session re-writes the whole cache.
|
|
||||||
- A repo's `.claude/settings*.json` `env` overrides the pane's
|
|
||||||
`ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (seen in this repo) — give the
|
|
||||||
gateway through `--settings=<file>`, the layer that outranks project settings,
|
|
||||||
as AI Proxy panes already do.
|
|
||||||
Not checked yet (interactive features that call claude.ai with the user's own
|
|
||||||
login, likely unavailable): `/usage` quota view, claude.ai MCP connectors,
|
|
||||||
Remote Control / web sessions, org plugins from claude.ai.
|
|
||||||
|
|
||||||
What is and is not guaranteed:
|
What is and is not guaranteed:
|
||||||
- **Guaranteed:** no hub account (or a disabled user / revoked device) → no
|
- **Guaranteed:** no hub account / disabled user / revoked device → no company
|
||||||
issued credential, because the credential never leaves the hub. This holds
|
credential, because it never leaves the hub — as long as the admin alone
|
||||||
only while the ADMIN alone holds each shared seat's login (setup-tokens
|
holds each shared seat's login.
|
||||||
made by the admin). A seat whose login (email/SSO) an employee knows can
|
- **Not guaranteed:** that the AgentHub app was used. A device token copied out
|
||||||
always be used directly at claude.ai or in a plain `claude` — AgentHub cannot
|
and used with a plain `claude` is caught by the registration check, unless
|
||||||
stop that.
|
the user also fakes a registration. Hard enforcement needs managed machines.
|
||||||
- **Not guaranteed:** that the AgentHub APP was used. The device token sits on
|
- Quota and budget on company accounts stay **real-time** despite the hourly
|
||||||
a machine the user controls; `block` stops a plain `claude` using it, unless
|
upload: the gateway counts `usage` from each response it relays.
|
||||||
the user also fakes the upload stream. Hard enforcement needs managed,
|
|
||||||
locked-down machines.
|
## What already exists (2026-11-04)
|
||||||
- **Closes the visibility gap (R3b):** the gateway sees every request body, so
|
|
||||||
it can record the conversation of every pool request ITSELF — new messages
|
| Need | In the code | Missing |
|
||||||
per request by session id, plus the streamed answer and its `usage` — no
|
|---|---|---|
|
||||||
matter which client sent it. Then nothing that uses an issued credential is
|
| Hooks from another machine | `POST /api/hooks/ingest-batch` + `push-token.js` (`push:<source>`) | one hub-wide token, no user/device; transcript dropped |
|
||||||
invisible to the admin, AgentHub app or not.
|
| Credential swap | `proxy-gateway.js` (pane token → real key, SSE streamed) | loopback + pane token only |
|
||||||
- **Plain HTTP:** a LAN sniffer can take a device token and act as that user.
|
| Shared accounts | pools, pool groups, pins, own choice, `refreshLogins` | a path for a remote caller |
|
||||||
If that matters, HTTPS (reverse proxy + an internal CA every machine trusts,
|
| Spend per user | `pool-usage.js`, `user-quota.js`, `session-account.js`, Administration user detail | ownership of a session with no lane |
|
||||||
`NODE_EXTRA_CA_CERTS` for Claude Code).
|
| Transcript import | `importFromDirectory`, `codex-usage.js` | an upload path |
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
Each one ships alone and changes nothing for a local-only install.
|
Each ships alone and changes nothing for a local-only install.
|
||||||
|
|
||||||
**R0 — user activity view (hub, useful today).** Users page row → a user
|
**B1 — devices and ownership.** Table `devices` (id, user_id, name,
|
||||||
page: their devices (once R1 exists), workspaces, and sessions by day with
|
token_hash, created_at, last_seen_at, revoked_at); `agenthub remote login
|
||||||
tokens/cost; a session opens the existing session page (conversation, tool
|
http://10.1.13.7:4820` (hub password, throttled like `/api/auth/login`) stores
|
||||||
calls). Backend: an `owner=<user id>` filter for staff on `GET /api/sessions`
|
an `ahdev_` token 0600 in the runtime dir; an admin lists and revokes devices
|
||||||
and `GET /api/lanes`, reusing `sessionWhere`/`laneWhere` for that user — no
|
on the user's page (step-up + `audit_log`). Column `sessions.owner_id`,
|
||||||
new query logic. Admin reads are audited as they already are.
|
written from the device's user; `sessionWhere` honours it, so scoping needs no
|
||||||
Verify: server test for the filter (developer cannot use it); client snapshot.
|
lane. Verify: mint/hash/revoke tests; revoked → 401; a developer sees only
|
||||||
|
their own device sessions.
|
||||||
|
|
||||||
**R1 — devices (hub + CLI).** Table `devices` (id, user_id, name, token_hash,
|
**B2 — gateway for the Claude pool over LAN.** `proxy-gateway.js` gains the
|
||||||
created_at, last_seen_at, revoked_at). `agenthub remote login
|
device-token path: heartbeat check, `pickAccount` + session-sticky account,
|
||||||
http://10.1.13.7:4820` signs in with the user's hub password (throttled like
|
`usage` counted per response. The local `spawnRun`, on an enrolled machine and
|
||||||
`/api/auth/login`), receives an `ahdev_` token once, stores it 0600 in the
|
only when the user picks a company account, points the pane at the hub through
|
||||||
runtime dir. The user page lists devices; an admin revokes one (re-auth +
|
`--settings=` and sets `ENABLE_PROMPT_CACHING_1H=1`. Spike first on one real
|
||||||
`audit_log`).
|
pool account. Verify: a pane answers "pong"; a curl with a device token and an
|
||||||
Verify: mint/hash/revoke tests; a revoked token gets 401.
|
unknown session id is logged (`warn`) or 403 (`block`).
|
||||||
|
|
||||||
**R2 — upload (both sides).** An enrolled local AgentHub uploads each of ITS
|
**B3 — registration and hourly upload.** For company-account sessions only:
|
||||||
sessions (`dashboard_runs`): hook events as they arrive, the session files of
|
one registration at start; conversation (Claude transcript, Codex rollout) and
|
||||||
the table above (Claude transcript, Codex rollout, agy conversation)
|
tokens hourly and at session end, incremental by byte offset, disk queue,
|
||||||
incrementally (byte offset, retry queue on disk, never blocking a hook). The
|
never blocking a hook. The hub imports with `importFromDirectory` so cost
|
||||||
hub stores transcripts under `<data dir>/remote/<user id>/<device id>/projects/`
|
matches local sessions; shown device › folder on the user's page.
|
||||||
and imports them with the SAME `importFromDirectory` `remote-sync.js` uses, so
|
|
||||||
tokens and cost match local sessions. Sessions are owned by the device's user
|
|
||||||
(`source = device:<id>`); workspaces are mirrored as records (title, folder,
|
|
||||||
tool) — shown, not driven. The existing `ingest-batch` stays as it is.
|
|
||||||
Verify: two local instances → one hub; R0's page shows both users' work.
|
|
||||||
|
|
||||||
**R3 — remote gateway.** The local `spawnRun` points an enrolled machine's
|
**B4 — shared skills/plugins.** No AgentHub code: company marketplace (git) +
|
||||||
Claude pane at the hub (`ANTHROPIC_BASE_URL`, device token as
|
a `managed-settings.json` template for user machines.
|
||||||
`ANTHROPIC_AUTH_TOKEN`, through tmux `-e`). `proxy-gateway.js` (from
|
|
||||||
origin/main since 84e2a9f0) gains the remote path and checks above; for the pool it picks an
|
|
||||||
account with `pickAccount` and sends its OAuth token. **Spike first** on ONE
|
|
||||||
real pool account: which headers a setup-token needs through a relay.
|
|
||||||
Verify: a pane gets "pong"; a curl with the device token and a session id
|
|
||||||
nobody uploads is logged (`warn`) or 403 (`block`).
|
|
||||||
|
|
||||||
**R4 — shared skills/plugins.** No AgentHub code: the company marketplace (git)
|
Codex comes after B2 works for Claude (ChatGPT-login auth differs from an API
|
||||||
plus a `managed-settings.json` template (`extraKnownMarketplaces`,
|
key; its session-id header is unchecked).
|
||||||
`enabledPlugins`) and an install script for user machines.
|
|
||||||
|
|
||||||
Codex comes after R3 works for Claude: ChatGPT-account auth differs from an API
|
## Built (2026-11-04)
|
||||||
key, and its session-id header still has to be checked.
|
|
||||||
|
Hub side — `server/hub/`, loaded only in server mode (`server/index.js`
|
||||||
|
requires it inside `isServerMode()`; `server/__tests__/hub-boundary.test.js`
|
||||||
|
fails if anything outside the folder requires it):
|
||||||
|
|
||||||
|
| File | Does |
|
||||||
|
|---|---|
|
||||||
|
| `devices.js` | `ahdev_<32 bytes base64url>` tokens, only the SHA-256 kept; 90 days (`AGENTHUB_DEVICE_TOKEN_DAYS`); revoke |
|
||||||
|
| `remote-sessions.js` | registration (UUID, absolute cwd, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) |
|
||||||
|
| `remote-gateway.js` | `/api/remote/gw/v1/*`: device token → registered session (waits `AGENTHUB_REMOTE_REGISTER_GRACE_MS`, 5 s, for one in flight) → `claude-pool.forRemote` (sticky per session) → upstream `AGENTHUB_REMOTE_UPSTREAM`; usage read from a decompressed COPY of the answer (Anthropic sends it gzip/br) |
|
||||||
|
| `remote-ingest.js` | upload chunks at the hub's own offset (`409 EOFFSET` + `expected`), copy under `<data>/remote/u<uid>/d<did>/<sid>/`, `importFromDirectory` once per round (chunks carry `more: true`, the round's closing request imports; a round cut short is imported after `AGENTHUB_REMOTE_FLUSH_MS`, 2 min), then `sessions.source = device:<id>`, `owner_id`; `purgeOld` after `AGENTHUB_REMOTE_RETAIN_DAYS` (30) keeps token rows |
|
||||||
|
| `index.js` | `/api/remote/device/*` (device token: login, me, logout, sessions, end, files, upload), `/api/remote/*` (signed in: devices, revoke with password + audit, sessions, "who opened mine"), hourly purge |
|
||||||
|
|
||||||
|
Shared: `sessions.owner_id` (scope.js, session-account.js, users.js honour
|
||||||
|
it); `claude-pool.forRemote` and live remote sessions in the pool ranking;
|
||||||
|
`auth.checkLogin`.
|
||||||
|
|
||||||
|
Client side (local mode): `server/lib/remote-client.js` +
|
||||||
|
`/api/remote-client` + `agenthub remote login|status|upload|logout`, Settings →
|
||||||
|
AI tools → Company hub. The reserved AI Proxy name `agenthub.company` ("Company
|
||||||
|
account", Claude only) writes `<data>/proxy-settings/agenthub.company.json` with the
|
||||||
|
gateway URL, the device token and `ENABLE_PROMPT_CACHING_1H=1`. A company
|
||||||
|
session is registered at spawn, again on a new session id (SessionStart /
|
||||||
|
UserPromptSubmit hooks: `/clear`, a fork), uploaded hourly
|
||||||
|
(`DASHBOARD_REMOTE_UPLOAD_MS`) and soon after SessionEnd; every JSON line goes
|
||||||
|
through `privacy.redactValue` first.
|
||||||
|
|
||||||
|
Measured (2026-11-04): local mode loads none of `server/hub/` (216 server
|
||||||
|
modules vs 214 before: `remote-client.js` + its route); startup and RSS
|
||||||
|
unchanged within noise. E2E on one real pool account: answers, sticky account,
|
||||||
|
`/clear`, resume, hub totals = transcript totals, no device token or key in the
|
||||||
|
hub copy, revoke → 401, hub down → `503 EHUBDOWN` at start.
|
||||||
|
|
||||||
|
Known limits:
|
||||||
|
- Claude Code reads `ANTHROPIC_AUTH_TOKEN` once: a pane open across a
|
||||||
|
re-login (expired or revoked token) keeps failing with 401 until it is
|
||||||
|
resumed.
|
||||||
|
- An unmonitored session is flagged on the hub (`unmonitored`, a warning in
|
||||||
|
the log) and, once it has stayed unregistered for
|
||||||
|
`AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` (60 s), fires every enabled
|
||||||
|
**Company account outside AgentHub** alert rule (`remote_unmonitored`). A
|
||||||
|
registration that was only late clears the flag first.
|
||||||
|
- `AGENTHUB_REMOTE_GATE=block` is covered by unit tests only.
|
||||||
|
- A shared AI Proxy as a company account, and Codex, are not built.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Should an admin be able to mark a workspace "company account required"?
|
||||||
|
(Today's answer: no — a user's own login in any workspace is their business.)
|
||||||
|
|
||||||
## Open risks
|
## Open risks
|
||||||
|
|
||||||
- A hub outage stops every pool session; a machine can still use its own login.
|
- A hub outage stops every company-account session; the machine can still use
|
||||||
- The hub stores every conversation of an AgentHub session; tell users.
|
its own login.
|
||||||
|
- Every byte of a company-account session's context passes through the hub in
|
||||||
|
transit; tell users.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.66",
|
"version": "2.8.72",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.66",
|
"version": "2.8.72",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.66",
|
"version": "2.8.72",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file artifact-export-hint.js — a synchronous PostToolUse hook (matcher
|
||||||
|
* `Artifact` and the claude.ai connectors' `batch`) that asks Claude, right
|
||||||
|
* after it publishes a Claude Docs doc or a typed artifact (Slides, Design,
|
||||||
|
* Dashboard…), to also save a copy in the workspace at
|
||||||
|
* `artifacts-export/<artifact id>.<pdf|html|md>`. Such a page lives only in
|
||||||
|
* the publishing claude.ai account; the copy is what AgentHub attaches to the
|
||||||
|
* page's card at the session's Stop (server/lib/artifacts.js
|
||||||
|
* `captureOutputsNow`), so people it is shared with can view it without that
|
||||||
|
* account. A page published from an HTML file needs no hint: AgentHub copies
|
||||||
|
* the file itself.
|
||||||
|
*
|
||||||
|
* Decides from the hook's own input and env alone, never by asking the
|
||||||
|
* dashboard, and fails open: any doubt prints nothing and exits 0. Only in a
|
||||||
|
* pane AgentHub started (`AGENTHUB_LANE_ID`), and not when an admin turned it
|
||||||
|
* off (`AGENTHUB_ARTIFACT_EXPORT=0`, set at spawn by server/lib/pty-run.js).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_INPUT = 4 * 1024 * 1024;
|
||||||
|
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
|
||||||
|
const ARTIFACT_ID_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/([A-Za-z0-9_-]+)/;
|
||||||
|
|
||||||
|
/** The hint for one PostToolUse payload, or null when there is nothing to ask. */
|
||||||
|
function hintFor(data, env = process.env) {
|
||||||
|
if (!data || typeof data !== "object") return null;
|
||||||
|
if (!env.AGENTHUB_LANE_ID || env.AGENTHUB_ARTIFACT_EXPORT === "0") return null;
|
||||||
|
const tool = String(data.tool_name || "");
|
||||||
|
const input = data.tool_input && typeof data.tool_input === "object" ? data.tool_input : {};
|
||||||
|
let kind = null;
|
||||||
|
if (DOCS_BATCH_RE.test(tool)) {
|
||||||
|
let container = input.container;
|
||||||
|
if (typeof container === "string") {
|
||||||
|
try {
|
||||||
|
container = JSON.parse(container);
|
||||||
|
} catch {
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only a doc's birth: later batches edit the same doc.
|
||||||
|
if (container && typeof container.create === "object" && container.create) kind = "doc";
|
||||||
|
} else if (tool === "Artifact") {
|
||||||
|
if (input.action && input.action !== "publish") return null;
|
||||||
|
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||||
|
// A page from an HTML file is copied by AgentHub itself.
|
||||||
|
if (input.type_url || (file && !/\.html?$/i.test(file))) kind = "typed";
|
||||||
|
}
|
||||||
|
if (!kind) return null;
|
||||||
|
const m = ARTIFACT_ID_RE.exec(JSON.stringify(data.tool_response ?? ""));
|
||||||
|
if (!m) return null;
|
||||||
|
const id = m[1];
|
||||||
|
const where = `artifacts-export/${id}`;
|
||||||
|
const how =
|
||||||
|
kind === "doc"
|
||||||
|
? `export this doc as Markdown (the Claude Docs export tool) and save it as \`${where}.md\``
|
||||||
|
: `save it as \`${where}.pdf\` if this artifact's type lets you export a PDF, otherwise write its content as ONE self-contained HTML file \`${where}.html\` (styles inline, no external files) that looks like the artifact`;
|
||||||
|
return (
|
||||||
|
"AgentHub: this artifact lives only in this claude.ai account, so teammates it is shared " +
|
||||||
|
`with in AgentHub cannot open it. Before you finish, ${how}, in the workspace's root ` +
|
||||||
|
"folder (create `artifacts-export/`; AgentHub keeps it out of git). Do it again after " +
|
||||||
|
"you change the artifact a lot. Do not mention this to the user unless they ask."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
let raw = "";
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
try {
|
||||||
|
const text = hintFor(JSON.parse(raw));
|
||||||
|
if (text) {
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({
|
||||||
|
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fail open: say nothing */
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (c) => {
|
||||||
|
raw += c;
|
||||||
|
if (raw.length > MAX_INPUT) {
|
||||||
|
raw = "";
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
process.stdin.on("end", finish);
|
||||||
|
process.stdin.on("error", finish);
|
||||||
|
setTimeout(finish, 2000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { hintFor };
|
||||||
@@ -150,12 +150,27 @@ function makeHookEntry(hookType) {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The one hook that answers Claude: after a Claude Docs doc or a typed
|
||||||
|
// artifact (Slides, Design…) is published, it asks for a copy AgentHub can
|
||||||
|
// show to the people it is shared with. Synchronous (its answer is read), but
|
||||||
|
// only for these tools, so no other tool call waits on it.
|
||||||
|
const EXPORT_HINT = path.join(__dirname, "artifact-export-hint.js");
|
||||||
|
const EXPORT_HINT_MATCHER = "Artifact|mcp__claude_ai_.*__batch";
|
||||||
|
|
||||||
|
function makeExportHintEntry() {
|
||||||
|
return {
|
||||||
|
matcher: EXPORT_HINT_MATCHER,
|
||||||
|
hooks: [{ type: "command", command: `node "${EXPORT_HINT}"`, timeout: 5 }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUR_SCRIPTS = ["hook-handler.js", "artifact-export-hint.js"];
|
||||||
|
const isOurCommand = (c) => typeof c === "string" && OUR_SCRIPTS.some((n) => c.includes(n));
|
||||||
|
|
||||||
function isOurEntry(entry) {
|
function isOurEntry(entry) {
|
||||||
// Matches old format (entry.command) and new format (entry.hooks[].command)
|
// Matches old format (entry.command) and new format (entry.hooks[].command)
|
||||||
if (entry.command && entry.command.includes("hook-handler.js")) return true;
|
if (isOurCommand(entry.command)) return true;
|
||||||
if (Array.isArray(entry.hooks)) {
|
if (Array.isArray(entry.hooks)) return entry.hooks.some((h) => isOurCommand(h.command));
|
||||||
return entry.hooks.some((h) => h.command && h.command.includes("hook-handler.js"));
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +236,9 @@ function installHooks(silent = false) {
|
|||||||
for (const hookType of HOOK_TYPES) {
|
for (const hookType of HOOK_TYPES) {
|
||||||
if (!settings.hooks[hookType]) settings.hooks[hookType] = [];
|
if (!settings.hooks[hookType]) settings.hooks[hookType] = [];
|
||||||
|
|
||||||
const existing = settings.hooks[hookType].findIndex(isOurEntry);
|
const existing = settings.hooks[hookType].findIndex(
|
||||||
|
(e) => isOurEntry(e) && !JSON.stringify(e).includes("artifact-export-hint.js")
|
||||||
|
);
|
||||||
const entry = makeHookEntry(hookType);
|
const entry = makeHookEntry(hookType);
|
||||||
|
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
@@ -232,6 +249,11 @@ function installHooks(silent = false) {
|
|||||||
installed++;
|
installed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The export hint is a second PostToolUse entry of ours, kept once.
|
||||||
|
const post = settings.hooks.PostToolUse;
|
||||||
|
const hint = post.findIndex((e) => JSON.stringify(e).includes("artifact-export-hint.js"));
|
||||||
|
if (hint >= 0) post[hint] = makeExportHintEntry();
|
||||||
|
else post.push(makeExportHintEntry());
|
||||||
|
|
||||||
const dir = path.dirname(getSettingsPath());
|
const dir = path.dirname(getSettingsPath());
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @file member-guard.js
|
||||||
|
* @description Server mode, one Linux user per account (S3): the members'
|
||||||
|
* SYNCHRONOUS Claude Code hook, from /etc/claude-code/managed-settings.json
|
||||||
|
* (install-system.sh), run AS the member from /opt/agenthub/app. The box is
|
||||||
|
* shared and small, so it is for writing code only:
|
||||||
|
*
|
||||||
|
* - `PreToolUse` (matcher Bash): denies a test run or a container command
|
||||||
|
* (`isHeavy`) — the member commits, pulls the branch on their own machine
|
||||||
|
* and runs it there. Claude gets the reason and stops trying. An admin
|
||||||
|
* can allow them again (Settings → AI tools, server/lib/member-policy.js).
|
||||||
|
* - `UserPromptSubmit`: a member runs at most `MAX_AI_SESSIONS` AI sessions
|
||||||
|
* at once (server/lib/ai-procs.js). The dashboard refuses a pane past the
|
||||||
|
* limit itself (ESESSIONLIMIT); this catches a `claude` typed into a
|
||||||
|
* Terminal tab, by refusing every prompt of a session that is not among
|
||||||
|
* the account's oldest `limit` ones.
|
||||||
|
*
|
||||||
|
* Only `ah-*` accounts are guarded (the host is an admin's). Fail-open like
|
||||||
|
* every AgentHub hook: any error lets the call through. A person's own
|
||||||
|
* terminal commands and Codex (whose hooks are opt-in) are not covered.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const procs = require(path.join(__dirname, "..", "server", "lib", "ai-procs.js"));
|
||||||
|
const policy = require(path.join(__dirname, "..", "server", "lib", "member-policy.js"));
|
||||||
|
|
||||||
|
/** Test runners and container tools, by the program a command segment runs. */
|
||||||
|
const HEAVY_BINS = new Set([
|
||||||
|
"vitest",
|
||||||
|
"jest",
|
||||||
|
"mocha",
|
||||||
|
"ava",
|
||||||
|
"karma",
|
||||||
|
"playwright",
|
||||||
|
"cypress",
|
||||||
|
"phpunit",
|
||||||
|
"pest",
|
||||||
|
"paratest",
|
||||||
|
"pytest",
|
||||||
|
"py.test",
|
||||||
|
"tox",
|
||||||
|
"nox",
|
||||||
|
"rspec",
|
||||||
|
"ctest",
|
||||||
|
"nextest",
|
||||||
|
"docker",
|
||||||
|
"docker-compose",
|
||||||
|
"podman",
|
||||||
|
"podman-compose",
|
||||||
|
"nerdctl",
|
||||||
|
"buildah",
|
||||||
|
"kind",
|
||||||
|
"minikube",
|
||||||
|
"k3d",
|
||||||
|
]);
|
||||||
|
/** Wrappers that run the command after them. */
|
||||||
|
const PASS_THROUGH = new Set([
|
||||||
|
"sudo",
|
||||||
|
"env",
|
||||||
|
"time",
|
||||||
|
"nice",
|
||||||
|
"ionice",
|
||||||
|
"timeout",
|
||||||
|
"npx",
|
||||||
|
"bunx",
|
||||||
|
"pnpx",
|
||||||
|
"exec",
|
||||||
|
"command",
|
||||||
|
"xvfb-run",
|
||||||
|
"stdbuf",
|
||||||
|
]);
|
||||||
|
const TEST_SCRIPT = /^(test|tests|e2e|spec|unit|integration)([:._-].*)?$/;
|
||||||
|
|
||||||
|
/** The words of one command segment, wrappers and env assignments dropped. */
|
||||||
|
function words(segment) {
|
||||||
|
const w = segment
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((x) => x.replace(/^['"]|['"]$/g, ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
for (;;) {
|
||||||
|
while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift();
|
||||||
|
if (!w.length || !PASS_THROUGH.has(path.basename(w[0]))) break;
|
||||||
|
w.shift();
|
||||||
|
// a wrapper's own options and arguments (`timeout 60`, `nice -n 5`, `npx -y`)
|
||||||
|
while (w.length && (w[0].startsWith("-") || /^\d+[smhd]?$/.test(w[0]))) w.shift();
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this one segment run tests or containers? */
|
||||||
|
function segmentIsHeavy(segment) {
|
||||||
|
const w = words(segment);
|
||||||
|
if (!w.length) return false;
|
||||||
|
const bin = path.basename(w[0]);
|
||||||
|
const [a, b] = [w[1] || "", w[2] || ""];
|
||||||
|
if (HEAVY_BINS.has(bin)) return true;
|
||||||
|
if (["npm", "pnpm", "yarn", "bun", "composer"].includes(bin)) {
|
||||||
|
if (a === "t" || TEST_SCRIPT.test(a)) return true; // npm test, yarn test:unit
|
||||||
|
if (["run", "run-script", "exec", "x", "dlx"].includes(a)) {
|
||||||
|
return TEST_SCRIPT.test(b) || HEAVY_BINS.has(path.basename(b));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (["go", "deno", "dotnet", "mvn", "mvnw", "gradle", "gradlew"].includes(bin)) {
|
||||||
|
return ["test", "verify", "integrationTest"].includes(a);
|
||||||
|
}
|
||||||
|
if (bin === "cargo") return a === "test" || a === "nextest";
|
||||||
|
if (bin === "make") return w.slice(1).some((x) => TEST_SCRIPT.test(x) || x === "check");
|
||||||
|
if (bin === "php" || bin === "artisan") {
|
||||||
|
const rest = bin === "php" ? w.slice(1) : w;
|
||||||
|
const i = rest.findIndex((x) => /(^|\/)artisan$/.test(x));
|
||||||
|
if (i !== -1 && TEST_SCRIPT.test(rest[i + 1] || "")) return true;
|
||||||
|
return rest.some((x) => HEAVY_BINS.has(path.basename(x)));
|
||||||
|
}
|
||||||
|
if (/^python[0-9.]*$/.test(bin)) {
|
||||||
|
const i = w.indexOf("-m");
|
||||||
|
return i !== -1 && ["pytest", "unittest", "tox", "nox"].includes(w[i + 1]);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a Bash command run tests or containers anywhere in it? */
|
||||||
|
function isHeavy(command) {
|
||||||
|
if (typeof command !== "string") return false;
|
||||||
|
// Inner `bash -c "..."` / `sh -c '...'` bodies are checked too.
|
||||||
|
const inner = [...command.matchAll(/\b(?:ba|z|da)?sh\s+-l?c\s+(["'])([\s\S]*?)\1/g)].map(
|
||||||
|
(m) => m[2]
|
||||||
|
);
|
||||||
|
return [command, ...inner].some((c) => c.split(/&&|\|\||[;|&\n()]|\$\(|`/).some(segmentIsHeavy));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DENY_REASON =
|
||||||
|
"AgentHub: this shared server is for writing code only — tests and containers " +
|
||||||
|
"(vitest, jest, phpunit, pytest, npm test, docker, …) are not run here, because " +
|
||||||
|
"a few of them at once freeze it for the whole team. Do not try another way to " +
|
||||||
|
"run them. Finish the change, tell the user which tests to run, and let them " +
|
||||||
|
"commit, pull the branch on their own machine and test there.";
|
||||||
|
|
||||||
|
/** The admin's switch (Settings → AI tools), beside this script in the app root. */
|
||||||
|
const testsAllowed = () =>
|
||||||
|
policy.read(path.join(__dirname, "..", policy.FILE)).allowTests === true;
|
||||||
|
|
||||||
|
function guardTool(input, allowed = testsAllowed()) {
|
||||||
|
if (allowed || input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return;
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: "PreToolUse",
|
||||||
|
permissionDecision: "deny",
|
||||||
|
permissionDecisionReason: DENY_REASON,
|
||||||
|
},
|
||||||
|
}) + "\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardPrompt() {
|
||||||
|
const limit = procs.sessionLimit();
|
||||||
|
if (!limit) return;
|
||||||
|
const mine = procs.sessionOf(process.pid);
|
||||||
|
if (mine == null) return;
|
||||||
|
const all = procs.sessionsOf(process.getuid());
|
||||||
|
const at = all.findIndex((s) => s.pid === mine);
|
||||||
|
if (at < limit) return; // among the oldest `limit` (or not found: let it be)
|
||||||
|
process.stderr.write(
|
||||||
|
`AgentHub: you are running ${all.length} AI sessions — at most ${limit} at once on this ` +
|
||||||
|
"server, and this one started past the limit. Close another session (or this one) first.\n"
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const event = process.argv[2];
|
||||||
|
let raw = "";
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (c) => (raw += c));
|
||||||
|
process.stdin.on("end", () => {
|
||||||
|
try {
|
||||||
|
if (!os.userInfo().username.startsWith("ah-")) return;
|
||||||
|
if (event === "PreToolUse") guardTool(JSON.parse(raw || "{}"));
|
||||||
|
else if (event === "UserPromptSubmit") guardPrompt();
|
||||||
|
} catch {
|
||||||
|
/* fail-open */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { isHeavy, segmentIsHeavy, words, guardTool };
|
||||||
@@ -16,11 +16,13 @@
|
|||||||
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
|
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
|
||||||
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
|
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
|
||||||
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
|
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
|
||||||
|
# /opt/agenthub/libexec/tool-run wraps each command a member's Claude runs (OOM: it dies first)
|
||||||
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
|
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
|
||||||
# /etc/sudoers.d/agenthub checked with visudo before it is installed
|
# /etc/sudoers.d/agenthub checked with visudo before it is installed
|
||||||
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
||||||
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
|
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
|
||||||
# /etc/systemd/system/agenthub.service reads the host-owned env file
|
# /etc/systemd/system/agenthub.service reads the host-owned env file; caps
|
||||||
|
# the cgroup (server + every pane) at 85% of RAM, 1G swap
|
||||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ chmod 0644 /etc/agenthub/server.conf
|
|||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
|
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
|
||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
|
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
|
||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
|
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
|
||||||
|
install -m 0755 -o root -g root "$ROOT/scripts/server/tool-run.sh" /opt/agenthub/libexec/tool-run
|
||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
|
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
|
||||||
|
|
||||||
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
||||||
@@ -163,7 +166,14 @@ else
|
|||||||
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
|
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
|
||||||
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
|
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
|
||||||
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
|
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
|
||||||
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
|
const hooks = Object.fromEntries(ev.map((e) => [e, hook(e)]));
|
||||||
|
// The guard is synchronous (it decides): no test runs or containers from
|
||||||
|
// Claude, and at most MAX_AI_SESSIONS AI sessions per member (member-guard.js).
|
||||||
|
const guard = (e) => ({ type: "command", timeout: 5,
|
||||||
|
command: `/opt/agenthub/tools/bin/node /opt/agenthub/app/scripts/member-guard.js ${e}` });
|
||||||
|
hooks.PreToolUse.push({ matcher: "Bash", hooks: [guard("PreToolUse")] });
|
||||||
|
hooks.UserPromptSubmit.push({ hooks: [guard("UserPromptSubmit")] });
|
||||||
|
process.stdout.write(JSON.stringify({ hooks }, null, 2) + "\n");
|
||||||
' >"$MANAGED"
|
' >"$MANAGED"
|
||||||
chmod 0644 "$MANAGED"
|
chmod 0644 "$MANAGED"
|
||||||
sha256sum "$MANAGED" >"$MANAGED_MARK"
|
sha256sum "$MANAGED" >"$MANAGED_MARK"
|
||||||
@@ -203,6 +213,14 @@ ExecStart=/usr/bin/env \${AGENTHUB_NODE} \${AGENTHUB_ROOT}/server/index.js
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
KillMode=process
|
KillMode=process
|
||||||
|
# Every member pane lives in this cgroup. Capped below RAM with little swap
|
||||||
|
# (swap on a slow disk froze the whole box on 11-02), a runaway test run is
|
||||||
|
# OOM-killed inside the unit — pane-launch makes panes the first pick — and
|
||||||
|
# "continue" keeps the dashboard up when that happens.
|
||||||
|
MemoryMax=85%
|
||||||
|
MemorySwapMax=1G
|
||||||
|
OOMPolicy=continue
|
||||||
|
OOMScoreAdjust=-100
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
# the environment and execs the command; no shell ever parses a value.
|
# the environment and execs the command; no shell ever parses a value.
|
||||||
#
|
#
|
||||||
# The file is NUL-separated: the number of environment entries, the
|
# The file is NUL-separated: the number of environment entries, the
|
||||||
# NAME=value entries, then the argv (none = a login shell).
|
# NAME=value entries, then the argv (none = a login shell). Every pane starts
|
||||||
|
# at a lower CPU/IO priority, Claude's commands as the kernel's first OOM pick.
|
||||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
set -eu
|
set -eu
|
||||||
f=${1:?usage: pane-launch <launch file>}
|
f=${1:?usage: pane-launch <launch file>}
|
||||||
@@ -22,6 +23,27 @@ for kv in "${parts[@]:1:n}"; do
|
|||||||
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
|
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
|
||||||
export "$kv"
|
export "$kv"
|
||||||
done
|
done
|
||||||
|
# A member's pane shares the 4-core box with the dashboard and every other
|
||||||
|
# member, so it runs at a lower CPU/IO priority (a member cannot take the
|
||||||
|
# nice back: that needs root). Memory (11-02: 36 sessions plus test runs
|
||||||
|
# filled RAM and swap, and the box froze until a hard reset): the unit caps
|
||||||
|
# its cgroup below RAM (install-system.sh), and inside it the kernel kills
|
||||||
|
# by oom_score_adj — the dashboard and tmux at the unit's -100, an AI session
|
||||||
|
# at 300, a Terminal tab at 800, and every command Claude runs at 1000
|
||||||
|
# through CLAUDE_CODE_SHELL_PREFIX=tool-run. So a runaway command dies and
|
||||||
|
# the session that holds the conversation is told why; a member who lowers
|
||||||
|
# their own value can go no lower than -100.
|
||||||
|
renice -n 10 -p $$ >/dev/null 2>&1 || true
|
||||||
|
ionice -c 2 -n 7 -p $$ >/dev/null 2>&1 || true
|
||||||
|
TOOL_RUN=/opt/agenthub/libexec/tool-run
|
||||||
|
[ -x "$TOOL_RUN" ] && export CLAUDE_CODE_SHELL_PREFIX="${CLAUDE_CODE_SHELL_PREFIX:-$TOOL_RUN}"
|
||||||
|
# Vitest ≤ 3 starts one worker per core by default and these override the
|
||||||
|
# config; a member may set their own in the pane.
|
||||||
|
export VITEST_MAX_THREADS="${VITEST_MAX_THREADS:-2}" VITEST_MAX_FORKS="${VITEST_MAX_FORKS:-2}"
|
||||||
argv=("${parts[@]:n+1}")
|
argv=("${parts[@]:n+1}")
|
||||||
if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi
|
if [ ${#argv[@]} -eq 0 ]; then
|
||||||
|
echo 800 >/proc/self/oom_score_adj 2>/dev/null || true
|
||||||
|
exec -l "${SHELL:-/bin/bash}"
|
||||||
|
fi
|
||||||
|
echo 300 >/proc/self/oom_score_adj 2>/dev/null || true
|
||||||
exec "${argv[@]}"
|
exec "${argv[@]}"
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# @file tool-run.sh
|
||||||
|
# @description Server mode, one Linux user per account (S3): installed as
|
||||||
|
# /opt/agenthub/libexec/tool-run and named by CLAUDE_CODE_SHELL_PREFIX in
|
||||||
|
# every member pane (pane-launch). Claude Code runs each Bash command (and
|
||||||
|
# hook) as `tool-run '<command string>'`. The command runs at the kernel's
|
||||||
|
# highest OOM preference, so when memory runs out it is the command — a test
|
||||||
|
# run, a build — that is killed, never the Claude session above it that holds
|
||||||
|
# the conversation (pane-launch puts that one at 300). When the cgroup's OOM
|
||||||
|
# count moved while the command ran, Claude is told why it died.
|
||||||
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
[ $# -gt 0 ] || exec /bin/bash
|
||||||
|
echo 1000 >/proc/self/oom_score_adj 2>/dev/null
|
||||||
|
cg=$(sed -n 's/^0:://p' /proc/self/cgroup 2>/dev/null)
|
||||||
|
events=/sys/fs/cgroup${cg}/memory.events
|
||||||
|
kills() { sed -n 's/^oom_kill //p' "$events" 2>/dev/null; }
|
||||||
|
before=$(kills)
|
||||||
|
/bin/bash -c "$1"
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ] && [ -n "$before" ] && [ "$(kills)" != "$before" ]; then
|
||||||
|
printf '\n%s\n' "AgentHub: the server ran out of memory, so a process of this command was stopped (exit $rc). This shared server is for writing code only: do not run tests, containers or other heavy jobs here — tell the user to run them on their own machine." >&2
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
+1
-1
@@ -564,7 +564,7 @@ Request body shape:
|
|||||||
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
|
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
|
||||||
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
|
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
|
||||||
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
|
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
|
||||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree unless `changes: "carry"` — `409 ECARRYCONFLICT` with `files` when git would overwrite one — or `changes: "shelve"`; answers the target branch's `shelves`), `branch-create` (never switches unless `switch: true`) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. `shelve` (`{note?, paths?}`: `stash push --include-untracked` under an `agenthub-shelf:` message), `unshelve` (`{sha}`: clean tree only, `apply --index`, dropped only when clean, a conflict keeps it), `shelf-drop` (`{sha, confirm:true}`) — repo root only, never mid-operation, only entries AgentHub made (`GET /api/lanes/:id/git/shelves` lists them); `409 EAGENTRUNNING` until `confirm:true` while the workspace's agent is working. No rebase/hard reset by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree unless `changes: "carry"` — `409 ECARRYCONFLICT` with `files` when git would overwrite one — or `changes: "shelve"`; answers the target branch's `shelves`), `branch-create` (never switches unless `switch: true`) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. `shelve` (`{note?, paths?}`: `stash push --include-untracked` under an `agenthub-shelf:` message), `unshelve` (`{sha}`: nothing staged and none of its own files changed — `400 EDIRTY` with `paths` —, other files' changes stay; `apply --index`, dropped only when clean, a conflict keeps it), `shelf-drop` (`{sha, confirm:true}`) — repo root only, never mid-operation, only entries AgentHub made (`GET /api/lanes/:id/git/shelves` lists them); `409 EAGENTRUNNING` until `confirm:true` while the workspace's agent is working. No rebase/hard reset by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||||
|
|
||||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||||
an existing absolute git repository. The response workspace starts as
|
an existing absolute git repository. The response workspace starts as
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for scripts/artifact-export-hint.js: when Claude is asked to save
|
||||||
|
* a viewable copy of a published Doc / typed artifact, and when it is not.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { hintFor } = require("../../scripts/artifact-export-hint");
|
||||||
|
|
||||||
|
const PANE = { AGENTHUB_LANE_ID: "3" };
|
||||||
|
const docBirth = {
|
||||||
|
tool_name: "mcp__claude_ai_Claude_Docs__batch",
|
||||||
|
tool_input: { container: { kind: "project", create: { name: "Plan" } } },
|
||||||
|
tool_response: [{ type: "text", text: '{"frame":{"url":"https://claude.ai/artifact/doc-9"}}' }],
|
||||||
|
};
|
||||||
|
const slides = {
|
||||||
|
tool_name: "Artifact",
|
||||||
|
tool_input: { type_url: "https://claude.ai/types/slides", title: "Deck" },
|
||||||
|
tool_response: "Created https://claude.ai/artifact/deck-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("artifact export hint", () => {
|
||||||
|
it("asks for a Markdown copy of a new doc and a PDF/HTML copy of a typed artifact", () => {
|
||||||
|
assert.match(hintFor(docBirth, PANE), /artifacts-export\/doc-9\.md/);
|
||||||
|
const s = hintFor(slides, PANE);
|
||||||
|
assert.match(s, /artifacts-export\/deck-1\.pdf/);
|
||||||
|
assert.match(s, /artifacts-export\/deck-1\.html/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays silent everywhere else", () => {
|
||||||
|
assert.equal(hintFor(slides, {}), null, "not an AgentHub pane");
|
||||||
|
assert.equal(hintFor(slides, { ...PANE, AGENTHUB_ARTIFACT_EXPORT: "0" }), null, "turned off");
|
||||||
|
const html = { ...slides, tool_input: { file_path: "/w/page.html" } };
|
||||||
|
assert.equal(hintFor(html, PANE), null, "an HTML page is copied by AgentHub itself");
|
||||||
|
const edit = {
|
||||||
|
...docBirth,
|
||||||
|
tool_input: { container: { kind: "project", id: "p1" }, batch: [] },
|
||||||
|
};
|
||||||
|
assert.equal(hintFor(edit, PANE), null, "a later batch edits the doc");
|
||||||
|
assert.equal(hintFor({ ...slides, tool_response: "no link" }, PANE), null);
|
||||||
|
assert.equal(hintFor({ ...slides, tool_name: "ArtifactData" }, PANE), null);
|
||||||
|
assert.equal(hintFor(null, PANE), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints Claude Code's PostToolUse context, and nothing on bad input", () => {
|
||||||
|
const script = path.join(__dirname, "..", "..", "scripts", "artifact-export-hint.js");
|
||||||
|
const run = (input, env = PANE) =>
|
||||||
|
spawnSync(process.execPath, [script], {
|
||||||
|
input,
|
||||||
|
env: { PATH: process.env.PATH, ...env },
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
const ok = run(JSON.stringify(slides));
|
||||||
|
assert.equal(ok.status, 0);
|
||||||
|
const out = JSON.parse(ok.stdout);
|
||||||
|
assert.equal(out.hookSpecificOutput.hookEventName, "PostToolUse");
|
||||||
|
assert.match(out.hookSpecificOutput.additionalContext, /deck-1/);
|
||||||
|
const bad = run("{not json");
|
||||||
|
assert.equal(bad.status, 0);
|
||||||
|
assert.equal(bad.stdout, "");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -874,6 +874,101 @@ describe("sharing and visibility", () => {
|
|||||||
assert.equal(seen.unrecorded, 1);
|
assert.equal(seen.unrecorded, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shares with chosen people only: they see and list it, nobody else does", () => {
|
||||||
|
const addUser = db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO users (id, username, role, password_hash, disabled) VALUES (?, ?, 'developer', 'x', ?)"
|
||||||
|
);
|
||||||
|
addUser.run(701, "owner701", 0);
|
||||||
|
addUser.run(702, "ann702", 0);
|
||||||
|
addUser.run(703, "bob703", 0);
|
||||||
|
addUser.run(704, "gone704", 1);
|
||||||
|
const as = (id) => ({ method: "GET", user: { id, role: "developer", username: `u${id}` } });
|
||||||
|
const id = Number(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'for-ann', 701, 'https://claude.ai/ann')"
|
||||||
|
)
|
||||||
|
.run().lastInsertRowid
|
||||||
|
);
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
try {
|
||||||
|
// Only enabled, existing users; the owner is dropped from the list.
|
||||||
|
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [704] }), {
|
||||||
|
code: "EBADFIELD",
|
||||||
|
});
|
||||||
|
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [99999] }), {
|
||||||
|
code: "EBADFIELD",
|
||||||
|
});
|
||||||
|
assert.throws(() => artifacts.update(as(701), id, { sharedWith: "702" }), {
|
||||||
|
code: "EBADFIELD",
|
||||||
|
});
|
||||||
|
// Nobody but its owner may choose who sees it.
|
||||||
|
assert.throws(() => artifacts.update(as(702), id, { sharedWith: [702] }), {
|
||||||
|
code: "ENOTFOUND",
|
||||||
|
});
|
||||||
|
const v = artifacts.update(as(701), id, { sharedWith: [702, 701, 702] });
|
||||||
|
assert.equal(v.shared, false, "never to everyone");
|
||||||
|
assert.deepEqual(
|
||||||
|
v.sharedWith.map((p) => p.id),
|
||||||
|
[702]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(artifacts.canSeeId(as(702), id), true);
|
||||||
|
assert.equal(artifacts.canSeeId(as(703), id), false);
|
||||||
|
const ann = artifacts.list(as(702), { scope: "withme" });
|
||||||
|
assert.deepEqual(
|
||||||
|
ann.map((a) => a.title),
|
||||||
|
["for-ann"]
|
||||||
|
);
|
||||||
|
assert.equal(ann[0].sharedWithMe, true);
|
||||||
|
assert.deepEqual(ann[0].sharedWith, [], "a recipient does not see who else got it");
|
||||||
|
assert.ok(!artifacts.list(as(702), { scope: "hub" }).some((a) => a.id === id));
|
||||||
|
assert.ok(!artifacts.list(as(703)).some((a) => a.id === id));
|
||||||
|
assert.deepEqual(artifacts.list(as(703), { scope: "withme" }), []);
|
||||||
|
assert.deepEqual(artifacts.shareRecipients(id), [702]);
|
||||||
|
|
||||||
|
// People to pick from: enabled accounts but the caller, names only.
|
||||||
|
const people = artifacts.people(as(701));
|
||||||
|
assert.ok(people.some((p) => p.id === 702 && p.name === "ann702"));
|
||||||
|
assert.ok(!people.some((p) => p.id === 701 || p.id === 704));
|
||||||
|
assert.deepEqual(Object.keys(people[0]).sort(), ["id", "name", "username"]);
|
||||||
|
|
||||||
|
// A recipient disabled since is not listed, so it never blocks an edit.
|
||||||
|
artifacts.update(as(701), id, { sharedWith: [702, 703] });
|
||||||
|
db.prepare("UPDATE users SET disabled = 1 WHERE id = 703").run();
|
||||||
|
const listed = artifacts.view(
|
||||||
|
db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id),
|
||||||
|
as(701)
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
listed.sharedWith.map((p) => p.id),
|
||||||
|
[702]
|
||||||
|
);
|
||||||
|
artifacts.update(as(701), id, { sharedWith: [702] });
|
||||||
|
db.prepare("UPDATE users SET disabled = 0 WHERE id = 703").run();
|
||||||
|
|
||||||
|
// Out of its owner's library it stays with Ann; taking Ann off deletes it.
|
||||||
|
assert.equal(artifacts.remove(as(701), id).kept, true);
|
||||||
|
assert.equal(artifacts.canSeeId(as(702), id), true);
|
||||||
|
const gone = artifacts.update(as(701), id, { sharedWith: [] });
|
||||||
|
assert.equal(gone.removed, true);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT 1 FROM artifact_shares WHERE artifact_id = ?").get(id),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const audited = db
|
||||||
|
.prepare("SELECT path FROM audit_log WHERE path LIKE '%for-ann%' ORDER BY id")
|
||||||
|
.all()
|
||||||
|
.map((r) => r.path);
|
||||||
|
assert.ok(
|
||||||
|
audited.some((a) => /shared with ann702/.test(a)),
|
||||||
|
audited.join(" | ")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a cross-origin write", async () => {
|
it("refuses a cross-origin write", async () => {
|
||||||
const r = await request(
|
const r = await request(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -1063,3 +1158,123 @@ describe("unowned rows", () => {
|
|||||||
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
|
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a page's own copy, viewable without its claude.ai account", () => {
|
||||||
|
it("copies the HTML file a page was published from, with the files it names", async () => {
|
||||||
|
const dir = path.join(ws, "pub");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, "deck.html"), '<link href="style.css"><h1>v1</h1>');
|
||||||
|
fs.writeFileSync(path.join(dir, "style.css"), "h1{color:red}");
|
||||||
|
fs.writeFileSync(path.join(dir, ".env"), "SECRET=1");
|
||||||
|
const row = artifacts.captureFromHook("s1", {
|
||||||
|
cwd: ws,
|
||||||
|
tool_input: {
|
||||||
|
action: "publish",
|
||||||
|
file_path: "pub/deck.html",
|
||||||
|
files: { "style.css": "pub/style.css", ".env": "pub/.env", "x.txt": "/etc/hostname" },
|
||||||
|
},
|
||||||
|
tool_response: "Published: https://claude.ai/artifact/copy-1",
|
||||||
|
});
|
||||||
|
await Promise.all(artifacts.__pageCopies());
|
||||||
|
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||||
|
const files = JSON.parse(kept.files);
|
||||||
|
assert.equal(files[0].path, "deck.html", "the page itself first");
|
||||||
|
// A secret-looking file and one outside the workspace are never copied.
|
||||||
|
assert.deepEqual(
|
||||||
|
files.map((f) => f.path),
|
||||||
|
["deck.html", "style.css"]
|
||||||
|
);
|
||||||
|
const shot = artifacts.snapshotFile(null, row.id, "deck.html");
|
||||||
|
assert.match(fs.readFileSync(shot.abs, "utf8"), /v1/);
|
||||||
|
|
||||||
|
// A republish of the same link takes the new version, same row.
|
||||||
|
fs.writeFileSync(path.join(dir, "deck.html"), "<h1>v2</h1>");
|
||||||
|
const again = artifacts.captureFromHook("s1", {
|
||||||
|
cwd: ws,
|
||||||
|
tool_input: { action: "publish", file_path: path.join(dir, "deck.html") },
|
||||||
|
tool_response: "https://claude.ai/artifact/copy-1",
|
||||||
|
});
|
||||||
|
assert.equal(again.id, row.id);
|
||||||
|
await Promise.all(artifacts.__pageCopies());
|
||||||
|
assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /v2/);
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", kept.snapshot)),
|
||||||
|
"the old copy is dropped"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never copies a file outside the workspace or one that is not HTML", async () => {
|
||||||
|
const outside = artifacts.captureFromHook("s1", {
|
||||||
|
cwd: ws,
|
||||||
|
tool_input: { action: "publish", file_path: "/etc/hosts.html" },
|
||||||
|
tool_response: "https://claude.ai/artifact/copy-out",
|
||||||
|
});
|
||||||
|
const typed = artifacts.captureFromHook("s1", {
|
||||||
|
cwd: ws,
|
||||||
|
tool_input: { action: "publish", file_path: "main.js", type_url: "https://claude.ai/t" },
|
||||||
|
tool_response: "https://claude.ai/artifact/copy-typed",
|
||||||
|
});
|
||||||
|
await Promise.all(artifacts.__pageCopies());
|
||||||
|
for (const r of [outside, typed]) {
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes an older page's copy on first view, once", async () => {
|
||||||
|
fs.writeFileSync(path.join(ws, "pub", "old.html"), "<p>old</p>");
|
||||||
|
const id = Number(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO artifacts (kind, title, lane_id, source, url) VALUES ('page', 'old', ?, ?, 'https://claude.ai/artifact/old-1')"
|
||||||
|
)
|
||||||
|
.run(lane.id, path.join(ws, "pub", "old.html")).lastInsertRowid
|
||||||
|
);
|
||||||
|
const r = await request("POST", `/api/artifacts/${id}/copy`, {});
|
||||||
|
assert.equal(r.status, 200, r.text);
|
||||||
|
assert.deepEqual(
|
||||||
|
r.body.item.files.map((f) => f.path),
|
||||||
|
["old.html"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches an exported copy of a Doc or Slides to its page, not as a second card", async () => {
|
||||||
|
const page = artifacts.captureFromHook("s1", {
|
||||||
|
tool_input: { action: "publish", type_url: "https://claude.ai/types/slides" },
|
||||||
|
tool_response: "https://claude.ai/artifact/deck-77",
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(ws, "artifacts-export"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ws, "artifacts-export", "deck-77.pdf"), "%PDF-1.4 deck");
|
||||||
|
fs.writeFileSync(path.join(ws, "artifacts-export", "nobody-1.md"), "# not ours");
|
||||||
|
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n;
|
||||||
|
const done = await artifacts.captureOutputs("s1");
|
||||||
|
assert.ok(done.some((r) => r.id === page.id));
|
||||||
|
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(page.id);
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(kept.files).map((f) => f.path),
|
||||||
|
["deck-77.pdf"]
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!done.some((r) => /artifacts-export/.test(r.source || "")),
|
||||||
|
"an export never becomes a file card"
|
||||||
|
);
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n, before);
|
||||||
|
// Unchanged: not taken again.
|
||||||
|
assert.ok(!(await artifacts.captureOutputs("s1")).some((r) => r.id === page.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns the copy hint off for new panes", async () => {
|
||||||
|
assert.equal(artifacts.exportsEnabled(), true);
|
||||||
|
const r = await request("PUT", "/api/artifacts/settings", { exportsEnabled: false });
|
||||||
|
assert.equal(r.status, 200, r.text);
|
||||||
|
assert.equal(r.body.exportsEnabled, false);
|
||||||
|
assert.equal(r.body.installsEnabled, true, "installs untouched");
|
||||||
|
assert.equal(
|
||||||
|
(await request("PUT", "/api/artifacts/settings", { exportsEnabled: "no" })).status,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
artifacts.setExportsEnabled(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file chat.test.js
|
|
||||||
* @description Chat between users (server/lib/chat.js, server/routes/chat.js):
|
|
||||||
* a direct chat is one per pair, a group's creator alone changes it, only a
|
|
||||||
* room's members read it — an admin included —, unread counts and read marks,
|
|
||||||
* files served only to members under a sandbox CSP, a message deleted only by
|
|
||||||
* its author, "delete chat" for the caller alone, a group disbanded only by its
|
|
||||||
* creator, local mode refused, and
|
|
||||||
* every chat frame reaches exactly the users in its `to` (scope.exactAudience).
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, it, before, after } = require("node:test");
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
const path = require("node:path");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const os = require("node:os");
|
|
||||||
|
|
||||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "chat-test-"));
|
|
||||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
||||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
|
||||||
const prevMode = process.env.AGENTHUB_MODE;
|
|
||||||
process.env.AGENTHUB_MODE = "server";
|
|
||||||
|
|
||||||
const { db } = require("../db");
|
|
||||||
const scope = require("../lib/scope");
|
|
||||||
|
|
||||||
const USERS = {
|
|
||||||
1: { id: 1, username: "boss", role: "admin" },
|
|
||||||
2: { id: 2, username: "an", role: "developer" },
|
|
||||||
3: { id: 3, username: "binh", role: "developer" },
|
|
||||||
4: { id: 4, username: "chi", role: "analyst" },
|
|
||||||
};
|
|
||||||
let srv;
|
|
||||||
let base;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
const ins = db.prepare(
|
|
||||||
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
|
||||||
);
|
|
||||||
for (const u of Object.values(USERS)) ins.run(u.id, u.username, u.role);
|
|
||||||
const express = require("express");
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, _res, next) => {
|
|
||||||
req.user = USERS[req.headers["x-user"]];
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use("/api/chat", require("../routes/chat"));
|
|
||||||
await new Promise((r) => (srv = app.listen(0, r)));
|
|
||||||
base = `http://127.0.0.1:${srv.address().port}/api/chat`;
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
srv?.close();
|
|
||||||
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
|
|
||||||
else process.env.AGENTHUB_MODE = prevMode;
|
|
||||||
fs.rmSync(TMP, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
async function call(user, method, p, body) {
|
|
||||||
const init = { method, headers: { "x-user": String(user) } };
|
|
||||||
if (body instanceof FormData) init.body = body;
|
|
||||||
else if (body !== undefined) {
|
|
||||||
init.body = JSON.stringify(body);
|
|
||||||
init.headers["content-type"] = "application/json";
|
|
||||||
}
|
|
||||||
const r = await fetch(base + p, init);
|
|
||||||
const type = r.headers.get("content-type") || "";
|
|
||||||
return {
|
|
||||||
status: r.status,
|
|
||||||
headers: r.headers,
|
|
||||||
body: type.includes("json") ? await r.json() : await r.text(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("chat", () => {
|
|
||||||
let dm;
|
|
||||||
let group;
|
|
||||||
|
|
||||||
it("lists the other enabled users to chat with", async () => {
|
|
||||||
const r = await call(2, "GET", "/people");
|
|
||||||
assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]);
|
|
||||||
assert.ok(r.body.people.every((p) => p.disabled === false));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens one direct chat per pair, whoever starts it", async () => {
|
|
||||||
const a = await call(2, "POST", "/rooms", { kind: "dm", userId: 3 });
|
|
||||||
assert.equal(a.status, 201);
|
|
||||||
const b = await call(3, "POST", "/rooms", { kind: "dm", userId: 2 });
|
|
||||||
assert.equal(b.status, 200);
|
|
||||||
assert.equal(b.body.room.id, a.body.room.id);
|
|
||||||
dm = a.body.room.id;
|
|
||||||
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 2 })).status, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a chat to its members — an admin gets 404 like a missing room", async () => {
|
|
||||||
const m = await call(2, "POST", `/rooms/${dm}/messages`, { body: "chào Bình" });
|
|
||||||
assert.equal(m.status, 201);
|
|
||||||
assert.equal((await call(1, "GET", `/rooms/${dm}/messages`)).status, 404);
|
|
||||||
assert.equal((await call(1, "POST", `/rooms/${dm}/messages`, { body: "x" })).status, 404);
|
|
||||||
assert.deepEqual((await call(1, "GET", "/rooms")).body.rooms, []);
|
|
||||||
const seen = await call(3, "GET", `/rooms/${dm}/messages`);
|
|
||||||
assert.deepEqual(
|
|
||||||
seen.body.messages.map((x) => x.body),
|
|
||||||
["chào Bình"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts unread messages until they are marked read", async () => {
|
|
||||||
let rooms = (await call(3, "GET", "/rooms")).body.rooms;
|
|
||||||
assert.equal(rooms[0].unread, 1);
|
|
||||||
assert.equal((await call(2, "GET", "/rooms")).body.rooms[0].unread, 0); // your own
|
|
||||||
await call(3, "POST", `/rooms/${dm}/read`, { upTo: rooms[0].lastMessage.id });
|
|
||||||
rooms = (await call(3, "GET", "/rooms")).body.rooms;
|
|
||||||
assert.equal(rooms[0].unread, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets a group's creator alone change it, and any member leave", async () => {
|
|
||||||
const g = await call(2, "POST", "/rooms", { kind: "group", name: "Team", userIds: [3] });
|
|
||||||
assert.equal(g.status, 201);
|
|
||||||
group = g.body.room.id;
|
|
||||||
assert.deepEqual(g.body.room.members, [2, 3]);
|
|
||||||
assert.equal((await call(3, "PATCH", `/rooms/${group}`, { add: [4] })).status, 403);
|
|
||||||
const up = await call(2, "PATCH", `/rooms/${group}`, { add: [4], name: "Team A" });
|
|
||||||
assert.deepEqual(up.body.room.members, [2, 3, 4]);
|
|
||||||
assert.equal(up.body.room.name, "Team A");
|
|
||||||
assert.equal((await call(4, "POST", `/rooms/${group}/leave`)).status, 200);
|
|
||||||
assert.equal((await call(4, "GET", `/rooms/${group}/messages`)).status, 404);
|
|
||||||
assert.equal((await call(2, "PATCH", `/rooms/${group}`, { add: [99] })).status, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores files and serves them only to members, sandboxed", async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("body", "ảnh nè");
|
|
||||||
fd.append("files", new Blob([Buffer.from("PNGDATA")], { type: "image/png" }), "màn hình.png");
|
|
||||||
fd.append("files", new Blob(["<script>x</script>"], { type: "text/html" }), "a.html");
|
|
||||||
const m = await call(3, "POST", `/rooms/${group}/messages`, fd);
|
|
||||||
assert.equal(m.status, 201);
|
|
||||||
const [img, page] = m.body.message.files;
|
|
||||||
assert.equal(img.name, "màn hình.png");
|
|
||||||
const got = await call(2, "GET", `/rooms/${group}/files/${img.id}`);
|
|
||||||
assert.equal(got.status, 200);
|
|
||||||
assert.equal(got.body, "PNGDATA");
|
|
||||||
assert.match(got.headers.get("content-security-policy"), /sandbox/);
|
|
||||||
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
|
|
||||||
assert.match(got.headers.get("content-disposition"), /^inline/);
|
|
||||||
const html = await call(2, "GET", `/rooms/${group}/files/${page.id}`);
|
|
||||||
assert.match(html.headers.get("content-disposition"), /^attachment/);
|
|
||||||
assert.equal((await call(1, "GET", `/rooms/${group}/files/${img.id}`)).status, 404);
|
|
||||||
assert.equal(
|
|
||||||
(await call(2, "GET", `/rooms/${group}/files/..%2f..%2fdashboard.db`)).status,
|
|
||||||
404
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets only the author delete a message, its files too", async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("body", "xoá tôi");
|
|
||||||
fd.append("files", new Blob(["x"], { type: "text/plain" }), "a.txt");
|
|
||||||
const m = (await call(2, "POST", `/rooms/${group}/messages`, fd)).body.message;
|
|
||||||
assert.equal((await call(3, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 403);
|
|
||||||
assert.equal((await call(1, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 404);
|
|
||||||
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 200);
|
|
||||||
const left = (await call(2, "GET", `/rooms/${group}/messages`)).body.messages;
|
|
||||||
assert.ok(!left.some((x) => x.id === m.id));
|
|
||||||
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
|
|
||||||
// A system line is nobody's to delete.
|
|
||||||
const sys = left.find((x) => x.kind === "system");
|
|
||||||
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${sys.id}`)).status, 403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes a direct chat for the caller only, until a new message", async () => {
|
|
||||||
await call(3, "POST", `/rooms/${dm}/messages`, { body: "cũ" });
|
|
||||||
const c = await call(2, "POST", `/rooms/${dm}/clear`);
|
|
||||||
assert.deepEqual(c.body, { hidden: true, purged: false });
|
|
||||||
assert.ok(!(await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === dm));
|
|
||||||
assert.deepEqual((await call(2, "GET", `/rooms/${dm}/messages`)).body.messages, []);
|
|
||||||
// The other person keeps everything.
|
|
||||||
assert.ok((await call(3, "GET", `/rooms/${dm}/messages`)).body.messages.length >= 2);
|
|
||||||
await call(3, "POST", `/rooms/${dm}/messages`, { body: "mới" });
|
|
||||||
const back = (await call(2, "GET", "/rooms")).body.rooms.find((r) => r.id === dm);
|
|
||||||
assert.equal(back.unread, 1);
|
|
||||||
assert.deepEqual(
|
|
||||||
(await call(2, "GET", `/rooms/${dm}/messages`)).body.messages.map((m) => m.body),
|
|
||||||
["mới"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides a cleared message's files from the one who cleared it", async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("files", new Blob(["x"], { type: "text/plain" }), "b.txt");
|
|
||||||
const m = (await call(3, "POST", `/rooms/${group}/messages`, fd)).body.message;
|
|
||||||
await call(2, "POST", `/rooms/${group}/clear`);
|
|
||||||
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
|
|
||||||
assert.equal((await call(3, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 200);
|
|
||||||
// A group stays in the list after its history is cleared.
|
|
||||||
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === group));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets only a group's creator disband it, for everyone", async () => {
|
|
||||||
const g = (await call(3, "POST", "/rooms", { kind: "group", name: "Tạm", userIds: [2] })).body
|
|
||||||
.room.id;
|
|
||||||
assert.equal((await call(2, "DELETE", `/rooms/${g}`)).status, 403);
|
|
||||||
assert.equal((await call(3, "DELETE", `/rooms/${g}`)).status, 200);
|
|
||||||
assert.equal((await call(2, "GET", `/rooms/${g}/messages`)).status, 404);
|
|
||||||
assert.equal((await call(3, "DELETE", `/rooms/${dm}`)).status, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes for real what everyone deleted, and a direct chat both deleted whole", async () => {
|
|
||||||
const { db } = require("../db");
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("body", "bí mật");
|
|
||||||
fd.append("files", new Blob(["y"], { type: "text/plain" }), "c.txt");
|
|
||||||
const room = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room.id;
|
|
||||||
const m = (await call(4, "POST", `/rooms/${room}/messages`, fd)).body.message;
|
|
||||||
const onDisk = path.join(TMP, "chat", String(room), m.files[0].id);
|
|
||||||
assert.ok(fs.existsSync(onDisk));
|
|
||||||
await call(4, "POST", `/rooms/${room}/clear`);
|
|
||||||
// One side only: still stored for the other.
|
|
||||||
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
|
|
||||||
assert.ok(fs.existsSync(onDisk));
|
|
||||||
const both = await call(3, "POST", `/rooms/${room}/clear`);
|
|
||||||
assert.equal(both.body.purged, true);
|
|
||||||
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
|
|
||||||
assert.equal(db.prepare("SELECT 1 FROM chat_rooms WHERE id = ?").get(room), undefined);
|
|
||||||
assert.ok(!fs.existsSync(onDisk));
|
|
||||||
// Opening it again starts afresh.
|
|
||||||
const again = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room;
|
|
||||||
assert.equal(again.lastMessage, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("purges a group's history once every member cleared it", async () => {
|
|
||||||
const { db } = require("../db");
|
|
||||||
const g = (await call(2, "POST", "/rooms", { kind: "group", userIds: [3] })).body.room.id;
|
|
||||||
const m = (await call(2, "POST", `/rooms/${g}/messages`, { body: "x" })).body.message;
|
|
||||||
await call(2, "POST", `/rooms/${g}/clear`);
|
|
||||||
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
|
|
||||||
await call(3, "POST", `/rooms/${g}/clear`);
|
|
||||||
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
|
|
||||||
// The group itself stays.
|
|
||||||
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === g));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tells every member's read mark (seen), never past the newest message", async () => {
|
|
||||||
const r = await call(3, "POST", `/rooms/${group}/read`, { upTo: 1e9 });
|
|
||||||
const room = (await call(2, "GET", "/rooms")).body.rooms.find((x) => x.id === group);
|
|
||||||
assert.equal(room.reads[3], r.body.lastReadId);
|
|
||||||
assert.equal(room.reads[3], room.lastMessage ? room.lastMessage.id : room.reads[3]);
|
|
||||||
assert.ok(r.body.lastReadId < 1e9);
|
|
||||||
assert.deepEqual([...scope.exactAudience("chat_read", { to: [2, 3] })], [2, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hands a group to its longest-standing member when the creator leaves", async () => {
|
|
||||||
const g = (await call(2, "POST", "/rooms", { kind: "group", name: "Kế", userIds: [3, 4] })).body
|
|
||||||
.room.id;
|
|
||||||
await call(2, "POST", `/rooms/${g}/leave`);
|
|
||||||
const room = (await call(3, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
|
|
||||||
assert.equal(room.createdBy, 3);
|
|
||||||
assert.equal((await call(3, "PATCH", `/rooms/${g}`, { name: "Kế 2" })).status, 200);
|
|
||||||
// The "left" and "renamed" lines never count as unread.
|
|
||||||
const four = (await call(4, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
|
|
||||||
assert.equal(four.unread, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still names a disabled user, and never offers them for a new chat", async () => {
|
|
||||||
const { db } = require("../db");
|
|
||||||
db.prepare("UPDATE users SET disabled = 1 WHERE id = 4").run();
|
|
||||||
try {
|
|
||||||
const chi = (await call(2, "GET", "/people")).body.people.find((p) => p.id === 4);
|
|
||||||
assert.equal(chi.disabled, true);
|
|
||||||
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 4 })).status, 400);
|
|
||||||
} finally {
|
|
||||||
db.prepare("UPDATE users SET disabled = 0 WHERE id = 4").run();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves nothing behind in the upload folder", async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("files", new Blob(["z"], { type: "text/plain" }), "d.txt");
|
|
||||||
assert.equal((await call(2, "POST", `/rooms/${group}/messages`, fd)).status, 201);
|
|
||||||
const fd2 = new FormData();
|
|
||||||
fd2.append("files", new Blob(["z"], { type: "text/plain" }), "e.txt");
|
|
||||||
assert.equal((await call(1, "POST", `/rooms/${group}/messages`, fd2)).status, 404);
|
|
||||||
assert.deepEqual(fs.readdirSync(path.join(TMP, "chat", ".incoming")), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses an empty message", async () => {
|
|
||||||
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is refused in local mode", async () => {
|
|
||||||
delete process.env.AGENTHUB_MODE;
|
|
||||||
try {
|
|
||||||
assert.equal((await call(2, "GET", "/rooms")).body.error.code, "ELOCALMODE");
|
|
||||||
} finally {
|
|
||||||
process.env.AGENTHUB_MODE = "server";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends a chat frame to exactly its `to`, an admin not included", () => {
|
|
||||||
const only = scope.exactAudience("chat_message", { roomId: 1, to: [2, 3] });
|
|
||||||
assert.deepEqual([...only], [2, 3]);
|
|
||||||
assert.deepEqual([...scope.exactAudience("chat_room", {})], []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -247,17 +247,48 @@ describe("shelves", () => {
|
|||||||
await assert.rejects(act("shelve", {}), { code: "EMERGING" });
|
await assert.rejects(act("shelve", {}), { code: "EMERGING" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores staged changes staged, and refuses a dirty tree", async () => {
|
it("restores staged changes staged, and refuses one of its files changed", async () => {
|
||||||
write("a.txt", "staged\n");
|
write("a.txt", "staged\n");
|
||||||
g("add", "a.txt");
|
g("add", "a.txt");
|
||||||
const { shelf } = await act("shelve", {});
|
const { shelf } = await act("shelve", {});
|
||||||
write("sub/s.txt", "new work\n");
|
write("a.txt", "new work\n");
|
||||||
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
|
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
|
||||||
g("checkout", "--", "sub/s.txt");
|
g("checkout", "--", "a.txt");
|
||||||
await act("unshelve", { sha: shelf.sha });
|
await act("unshelve", { sha: shelf.sha });
|
||||||
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
|
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores beside changes in other files, never over its own or a staged index", async () => {
|
||||||
|
// A partial Shelve (n): the unticked files stay behind and must not block
|
||||||
|
// the restore.
|
||||||
|
write("a.txt", "wip\n");
|
||||||
|
write("sub/new.txt", "new\n");
|
||||||
|
write("left.txt", "left behind\n");
|
||||||
|
const { shelf } = await act("shelve", { paths: ["a.txt", "sub/new.txt"] });
|
||||||
|
assert.equal(read("left.txt"), "left behind\n");
|
||||||
|
// Its own file changed since: refused, with the file named.
|
||||||
|
write("a.txt", "other\n");
|
||||||
|
await assert.rejects(act("unshelve", { sha: shelf.sha }), (e) => {
|
||||||
|
assert.equal(e.code, "EDIRTY");
|
||||||
|
assert.deepEqual(e.paths, ["a.txt"]);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
g("checkout", "--", "a.txt");
|
||||||
|
// Anything staged: refused, nothing written.
|
||||||
|
write("b.txt", "staged\n");
|
||||||
|
g("add", "b.txt");
|
||||||
|
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
|
||||||
|
assert.equal(fs.existsSync(path.join(REPO, "sub/new.txt")), false);
|
||||||
|
g("rm", "-q", "--cached", "b.txt");
|
||||||
|
const r = await act("unshelve", { sha: shelf.sha });
|
||||||
|
assert.equal(r.kept, false);
|
||||||
|
assert.equal(read("a.txt"), "wip\n");
|
||||||
|
assert.equal(read("sub/new.txt"), "new\n");
|
||||||
|
assert.equal(read("left.txt"), "left behind\n");
|
||||||
|
assert.equal(read("b.txt"), "staged\n");
|
||||||
|
assert.equal((await listShelves(REPO)).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a conflicted shelf and lets `resolve` settle its files", async () => {
|
it("keeps a conflicted shelf and lets `resolve` settle its files", async () => {
|
||||||
write("shared.txt", "mine\n");
|
write("shared.txt", "mine\n");
|
||||||
const { shelf } = await act("shelve", {});
|
const { shelf } = await act("shelve", {});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @file hub-boundary.test.js
|
||||||
|
* @description `server/hub/` (the remote-clients hub, server mode only) is
|
||||||
|
* loaded only behind `isServerMode()`: no file outside it may require it,
|
||||||
|
* except `server/index.js` on lines that test the mode themselves. And a
|
||||||
|
* local-mode app really leaves it out — no module under `server/hub/` is in
|
||||||
|
* the require cache after `createApp()` in local mode.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, "..", "..");
|
||||||
|
const HUB = path.join(ROOT, "server", "hub");
|
||||||
|
const SCAN = ["server", "bin", "scripts", "mcp", "statusline"];
|
||||||
|
const REQUIRE_HUB = /require\(\s*["'`][^"'`]*\bhub(?:\/[^"'`]*)?["'`]\s*\)/;
|
||||||
|
|
||||||
|
function jsFiles(dir) {
|
||||||
|
const out = [];
|
||||||
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (["node_modules", "__tests__", "dist"].includes(e.name) || full === HUB) continue;
|
||||||
|
out.push(...jsFiles(full));
|
||||||
|
} else if (/\.(c|m)?js$/.test(e.name)) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("server/hub boundary", () => {
|
||||||
|
it("nothing outside server/hub requires it, except index.js behind isServerMode()", () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const top of SCAN) {
|
||||||
|
const dir = path.join(ROOT, top);
|
||||||
|
if (!fs.existsSync(dir)) continue;
|
||||||
|
for (const file of jsFiles(dir)) {
|
||||||
|
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (!REQUIRE_HUB.test(line)) return;
|
||||||
|
const rel = path.relative(ROOT, file);
|
||||||
|
if (rel === path.join("server", "index.js") && /isServerMode\(\)/.test(line)) return;
|
||||||
|
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(offenders, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a local-mode app loads no module of server/hub", () => {
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "hub-boundary-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
try {
|
||||||
|
require("../index").createApp();
|
||||||
|
const loaded = Object.keys(require.cache).filter((k) => k.startsWith(HUB + path.sep));
|
||||||
|
assert.deepEqual(loaded, []);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,6 +102,17 @@ describe("install-hooks host-only guard (#193)", () => {
|
|||||||
JSON.stringify(e).includes("hook-handler.js")
|
JSON.stringify(e).includes("hook-handler.js")
|
||||||
);
|
);
|
||||||
assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run");
|
assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run");
|
||||||
|
// The artifact export hint: one synchronous PostToolUse entry of its own,
|
||||||
|
// next to the (async) handler, matching only the publishing tools.
|
||||||
|
const post = settings.hooks.PostToolUse;
|
||||||
|
const handler = post.filter((e) => JSON.stringify(e).includes("hook-handler.js"));
|
||||||
|
const hint = post.filter((e) => JSON.stringify(e).includes("artifact-export-hint.js"));
|
||||||
|
assert.equal(handler.length, 1);
|
||||||
|
assert.equal(hint.length, 1);
|
||||||
|
assert.equal(hint[0].hooks[0].async, undefined);
|
||||||
|
assert.match(hint[0].matcher, /Artifact/);
|
||||||
|
const { isOurEntry } = require("../../scripts/install-hooks");
|
||||||
|
assert.equal(isOurEntry(hint[0]), true, "uninstall takes it out too");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("with the agenthub plugin enabled, adds nothing and removes our duplicate entries", () => {
|
it("with the agenthub plugin enabled, adds nothing and removes our duplicate entries", () => {
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ describe("local mode: one account", () => {
|
|||||||
assert.equal(r.body.prefs["agenthub.workspace.groups"], undefined);
|
assert.equal(r.body.prefs["agenthub.workspace.groups"], undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the Artifacts folders, up to 64 KB", async () => {
|
||||||
|
const folders = JSON.stringify({ folders: [{ id: "f1", name: "Slides" }], of: { 3: "f1" } });
|
||||||
|
let r = await call("PUT", "/api/me/prefs", {
|
||||||
|
body: { prefs: { "agenthub.artifacts.folders": folders + "x".repeat(20 * 1024) } },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
r = await call("PUT", "/api/me/prefs", {
|
||||||
|
body: { prefs: { "agenthub.artifacts.folders": "x".repeat(65 * 1024) } },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
||||||
let r = await call("GET", "/api/me/instructions");
|
let r = await call("GET", "/api/me/instructions");
|
||||||
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the S3 members' synchronous hook (scripts/member-guard.js)
|
||||||
|
* and the AI-session counter it shares with spawnRun (server/lib/ai-procs.js):
|
||||||
|
* which Bash commands count as a test run or a container command, and which
|
||||||
|
* processes count as one top-level AI session, read from a fake /proc.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const { isHeavy, guardTool } = require("../../scripts/member-guard.js");
|
||||||
|
const procs = require("../lib/ai-procs.js");
|
||||||
|
|
||||||
|
describe("member-guard: a test run or a container command", () => {
|
||||||
|
const heavy = [
|
||||||
|
"npx vitest run",
|
||||||
|
"vitest",
|
||||||
|
"npm test",
|
||||||
|
"npm t",
|
||||||
|
"npm run test:unit",
|
||||||
|
"pnpm test",
|
||||||
|
"yarn test --watch=false",
|
||||||
|
"bun test",
|
||||||
|
"cd client && NODE_ENV=test npx vitest related src/a.ts --run",
|
||||||
|
"timeout 300 npx jest",
|
||||||
|
"./vendor/bin/phpunit --filter Foo",
|
||||||
|
"php artisan test",
|
||||||
|
"vendor/bin/pest",
|
||||||
|
"python -m pytest -q",
|
||||||
|
"pytest tests/",
|
||||||
|
"go test ./...",
|
||||||
|
"cargo test",
|
||||||
|
"docker compose up -d",
|
||||||
|
"docker-compose up",
|
||||||
|
"podman run alpine",
|
||||||
|
"node_modules/.bin/playwright test",
|
||||||
|
'bash -c "npm run e2e"',
|
||||||
|
"make test",
|
||||||
|
"npm run build && npm test 2>&1 | tail -20",
|
||||||
|
];
|
||||||
|
const fine = [
|
||||||
|
"npm run build",
|
||||||
|
"npm install -D vitest",
|
||||||
|
"git commit -m 'add vitest tests'",
|
||||||
|
"rg -n docker src/",
|
||||||
|
"cat tests/a.test.js",
|
||||||
|
"ls node_modules/.bin/vitest",
|
||||||
|
"echo run npm test later",
|
||||||
|
"npx tsc -b",
|
||||||
|
"npm run lint",
|
||||||
|
"php artisan migrate:status",
|
||||||
|
"go build ./...",
|
||||||
|
"git log --grep test",
|
||||||
|
"composer install",
|
||||||
|
];
|
||||||
|
for (const c of heavy) it(`refuses: ${c}`, () => assert.equal(isHeavy(c), true));
|
||||||
|
for (const c of fine) it(`allows: ${c}`, () => assert.equal(isHeavy(c), false));
|
||||||
|
|
||||||
|
it("answers Claude Code with a PreToolUse deny and its reason, and nothing for the rest", () => {
|
||||||
|
const out = [];
|
||||||
|
const write = process.stdout.write;
|
||||||
|
process.stdout.write = (s) => out.push(String(s));
|
||||||
|
try {
|
||||||
|
guardTool({ tool_name: "Bash", tool_input: { command: "npm test" } });
|
||||||
|
guardTool({ tool_name: "Bash", tool_input: { command: "npm run build" } });
|
||||||
|
guardTool({ tool_name: "Read", tool_input: { file_path: "/x" } });
|
||||||
|
} finally {
|
||||||
|
process.stdout.write = write;
|
||||||
|
}
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
const o = JSON.parse(out[0]).hookSpecificOutput;
|
||||||
|
assert.equal(o.hookEventName, "PreToolUse");
|
||||||
|
assert.equal(o.permissionDecision, "deny");
|
||||||
|
assert.match(o.permissionDecisionReason, /own machine/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets everything through once an admin allows tests", () => {
|
||||||
|
const out = [];
|
||||||
|
const write = process.stdout.write;
|
||||||
|
process.stdout.write = (s) => out.push(String(s));
|
||||||
|
try {
|
||||||
|
guardTool({ tool_name: "Bash", tool_input: { command: "npx vitest run" } }, true);
|
||||||
|
} finally {
|
||||||
|
process.stdout.write = write;
|
||||||
|
}
|
||||||
|
assert.equal(out.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A fake /proc: one dir per pid with stat, cmdline; owner is this process (uid). */
|
||||||
|
function fakeProc(list) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ah-proc-"));
|
||||||
|
for (const p of list) {
|
||||||
|
const d = path.join(root, String(p.pid));
|
||||||
|
fs.mkdirSync(d);
|
||||||
|
// pid (comm) state ppid ... field 22 = starttime (index 19 after the ')')
|
||||||
|
const fields = ["S", String(p.ppid), ...Array(17).fill("0"), String(p.start)];
|
||||||
|
fs.writeFileSync(path.join(d, "stat"), `${p.pid} (x y) ${fields.join(" ")}`);
|
||||||
|
fs.writeFileSync(path.join(d, "cmdline"), p.argv.join("\0") + "\0");
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("member-policy: the admin's switch, read by the guard", () => {
|
||||||
|
it("blocks tests without a file, and writes a world-readable one", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-app-"));
|
||||||
|
const prev = process.env.AGENTHUB_APP_ROOT;
|
||||||
|
process.env.AGENTHUB_APP_ROOT = dir;
|
||||||
|
try {
|
||||||
|
const policy = require("../lib/member-policy.js");
|
||||||
|
assert.equal(policy.read().allowTests, false);
|
||||||
|
fs.writeFileSync(path.join(dir, policy.FILE), "not json");
|
||||||
|
assert.equal(policy.read().allowTests, false);
|
||||||
|
assert.equal(policy.setAllowTests(true).allowTests, true);
|
||||||
|
const f = path.join(dir, policy.FILE);
|
||||||
|
assert.equal(fs.statSync(f).mode & 0o777, 0o644);
|
||||||
|
assert.equal(policy.read(f).allowTests, true);
|
||||||
|
assert.equal(policy.setAllowTests(false).allowTests, false);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.AGENTHUB_APP_ROOT;
|
||||||
|
else process.env.AGENTHUB_APP_ROOT = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-procs: a member's top-level AI sessions", () => {
|
||||||
|
const uid = process.getuid();
|
||||||
|
const root = fakeProc([
|
||||||
|
{ pid: 10, ppid: 1, start: 1, argv: ["tmux"] },
|
||||||
|
{ pid: 11, ppid: 10, start: 5, argv: ["/opt/agenthub/tools/bin/claude", "--resume", "x"] },
|
||||||
|
{ pid: 12, ppid: 11, start: 6, argv: ["/bin/bash", "-c", "claude -p hi"] },
|
||||||
|
{ pid: 13, ppid: 12, start: 7, argv: ["claude", "-p", "hi"] }, // nested: not counted
|
||||||
|
{ pid: 20, ppid: 10, start: 3, argv: ["bash"] }, // a Terminal tab
|
||||||
|
{ pid: 21, ppid: 20, start: 4, argv: ["node", "/x/@openai/codex/bin/codex.js"] },
|
||||||
|
{ pid: 22, ppid: 21, start: 4, argv: ["/x/codex", "--yolo"] }, // Codex's native child
|
||||||
|
{ pid: 30, ppid: 10, start: 9, argv: ["omp", "auth-gateway", "serve"] }, // a server
|
||||||
|
{ pid: 31, ppid: 10, start: 8, argv: ["vitest"] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("counts each session once, oldest first, and no tool server", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
procs.sessionsOf(uid, root).map((s) => s.pid),
|
||||||
|
[21, 11]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("finds the session a hook process runs under", () => {
|
||||||
|
assert.equal(procs.sessionOf(13, root), 11);
|
||||||
|
assert.equal(procs.sessionOf(31, root), null);
|
||||||
|
});
|
||||||
|
it("reads the limit from server.conf, 4 without it, 0 = off", () => {
|
||||||
|
const conf = path.join(root, "server.conf");
|
||||||
|
assert.equal(procs.sessionLimit(conf), 4);
|
||||||
|
fs.writeFileSync(conf, "HOST_USER=x\nMAX_AI_SESSIONS=0\n");
|
||||||
|
assert.equal(procs.sessionLimit(conf), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
* Path parameters are compared by position only (`:token` and `{ref}` match),
|
* Path parameters are compared by position only (`:token` and `{ref}` match),
|
||||||
* and the two alias mounts that the spec documents by their tag description
|
* and the two alias mounts that the spec documents by their tag description
|
||||||
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
|
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
|
||||||
* are allowlisted. It also fails on a spec operation no route answers.
|
* are allowlisted. It also fails on a spec operation no route answers. The
|
||||||
|
* app is built twice — local and server mode — since only server mode mounts
|
||||||
|
* the hub's routes (server/hub/).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -83,7 +85,16 @@ describe("OpenAPI coverage", () => {
|
|||||||
let documented;
|
let documented;
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
routes = collectRoutes(createApp());
|
// Server mode mounts the hub's routes too (server/hub/): check both apps.
|
||||||
|
const local = collectRoutes(createApp());
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
let hub;
|
||||||
|
try {
|
||||||
|
hub = collectRoutes(createApp());
|
||||||
|
} finally {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
}
|
||||||
|
routes = [...new Set([...local, ...hub])];
|
||||||
documented = new Set();
|
documented = new Set();
|
||||||
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
|
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
|
||||||
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
|
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* @file remote-client.test.js
|
||||||
|
* @description A LOCAL AgentHub's side of the remote-clients architecture
|
||||||
|
* (server/lib/remote-client.js, /api/remote-client), against a fake hub:
|
||||||
|
* enrollment keeps the device token in a 0600 file and never answers it; the
|
||||||
|
* Company account is Claude-only, enrolled-only, and points a pane's
|
||||||
|
* `--settings` at the hub's gateway; a resume stays on it; only sessions
|
||||||
|
* started on it are uploaded — whole lines, secret-shaped strings replaced,
|
||||||
|
* from where the hub stands (a 409 resyncs), the end sent once; a pane that
|
||||||
|
* gets a new session id registers it; logout revokes on the hub.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const http = require("node:http");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "remote-client-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const remote = require("../lib/remote-client");
|
||||||
|
const { resolveProxy, resumeProxy, proxyForSpawn } = require("../lib/proxy-apply");
|
||||||
|
|
||||||
|
const TOKEN = `ahdev_${"t".repeat(43)}`;
|
||||||
|
let hub;
|
||||||
|
let HUB;
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const hubSeen = { registered: [], uploads: [], logout: 0 };
|
||||||
|
const consumed = new Map();
|
||||||
|
let forceOffset = null;
|
||||||
|
/** Sessions the hub answers ENOSESSION for until they are registered again. */
|
||||||
|
const forgotten = new Set();
|
||||||
|
/** Sessions the hub already purged. */
|
||||||
|
const purged = new Set();
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((r) => {
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (c) => (raw += c));
|
||||||
|
req.on("end", () => r(raw ? JSON.parse(raw) : {}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
hub = http.createServer(async (req, res) => {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const json = (status, obj) => {
|
||||||
|
res.writeHead(status, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
if (req.url === "/api/remote/device/login") {
|
||||||
|
if (body.password !== "right-pass") {
|
||||||
|
return json(401, { error: { code: "EBADLOGIN", message: "wrong username or password" } });
|
||||||
|
}
|
||||||
|
return json(200, {
|
||||||
|
token: TOKEN,
|
||||||
|
device: {
|
||||||
|
id: 7,
|
||||||
|
name: body.deviceName,
|
||||||
|
expiresAt: new Date(Date.now() + 90 * 864e5).toISOString(),
|
||||||
|
},
|
||||||
|
user: { id: 3, username: body.username, role: "developer" },
|
||||||
|
retainDays: 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.headers.authorization !== `Bearer ${TOKEN}`) {
|
||||||
|
return json(401, { error: { code: "EDEVICE", message: "no" } });
|
||||||
|
}
|
||||||
|
if (req.url === "/api/remote/device/me") return json(200, { device: { id: 7 } });
|
||||||
|
if (req.url === "/api/remote/device/logout") {
|
||||||
|
hubSeen.logout++;
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
|
if (req.url === "/api/remote/device/sessions") {
|
||||||
|
hubSeen.registered.push(body);
|
||||||
|
forgotten.delete(body.sessionId);
|
||||||
|
return json(200, { ok: true, sessionId: body.sessionId });
|
||||||
|
}
|
||||||
|
if (req.url === "/api/remote/device/upload") {
|
||||||
|
hubSeen.uploads.push(body);
|
||||||
|
if (forgotten.has(body.sessionId)) {
|
||||||
|
return json(404, { error: { code: "ENOSESSION", message: "not this device's" } });
|
||||||
|
}
|
||||||
|
if (purged.has(body.sessionId)) {
|
||||||
|
return json(410, { error: { code: "EPURGED", message: "removed" } });
|
||||||
|
}
|
||||||
|
const files = [];
|
||||||
|
for (const f of body.files) {
|
||||||
|
const key = `${body.sessionId}/${f.path}`;
|
||||||
|
const at = forceOffset ?? consumed.get(key) ?? 0;
|
||||||
|
if (f.from !== at) {
|
||||||
|
forceOffset = null;
|
||||||
|
return json(409, {
|
||||||
|
error: { code: "EOFFSET", message: "offset", expected: { path: f.path, consumed: at } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
consumed.set(key, f.to);
|
||||||
|
files.push({ path: f.path, consumed: f.to });
|
||||||
|
}
|
||||||
|
return json(200, { files, imported: true });
|
||||||
|
}
|
||||||
|
json(404, { error: { code: "ENOTFOUND", message: req.url } });
|
||||||
|
});
|
||||||
|
await new Promise((r) => hub.listen(0, "127.0.0.1", r));
|
||||||
|
HUB = `http://127.0.0.1:${hub.address().port}`;
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
server?.close();
|
||||||
|
hub?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const local = (method, p, body) =>
|
||||||
|
fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { "content-type": "application/json", origin: BASE },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||||
|
|
||||||
|
/** A local session whose transcript is `lines`, optionally a Company-account one. */
|
||||||
|
function makeSession({ company, lines, runStatus = "running" }) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const dir = path.join(process.env.CLAUDE_HOME, "projects", "-w-p");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, `${id}.jsonl`);
|
||||||
|
fs.writeFileSync(file, lines);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd, transcript_path) VALUES (?, 'x', 'active', '/w/p', ?)"
|
||||||
|
).run(id, file);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO dashboard_runs (id, session_id, cwd, mode, status, proxy) VALUES (?, ?, '/w/p', 'interactive', ?, ?)`
|
||||||
|
).run(`run-${id}`, id, runStatus, company ? "agenthub.company" : null);
|
||||||
|
if (company) remote.registerSoon({ sessionId: id, cwd: "/w/p" });
|
||||||
|
return { id, file };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("enrollment", () => {
|
||||||
|
it("is off until login; a wrong password stores nothing", async () => {
|
||||||
|
const st = await local("GET", "/api/remote-client");
|
||||||
|
assert.equal(st.body.enrolled, false);
|
||||||
|
const bad = await local("POST", "/api/remote-client/login", {
|
||||||
|
hub: HUB,
|
||||||
|
username: "mia",
|
||||||
|
password: "wrong",
|
||||||
|
});
|
||||||
|
assert.equal(bad.status, 401);
|
||||||
|
assert.equal(remote.readState(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a hub address with a login in it or another scheme", () => {
|
||||||
|
assert.throws(() => remote.normalizeHub("http://u:p@10.1.13.7:4820"), { code: "EBADHUB" });
|
||||||
|
assert.throws(() => remote.normalizeHub("ftp://10.1.13.7"), { code: "EBADHUB" });
|
||||||
|
assert.equal(remote.normalizeHub("http://10.1.13.7:4820/"), "http://10.1.13.7:4820");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the token in a 0600 file and never answers it", async () => {
|
||||||
|
const r = await local("POST", "/api/remote-client/login", {
|
||||||
|
hub: `${HUB}/`,
|
||||||
|
username: "mia",
|
||||||
|
password: "right-pass",
|
||||||
|
deviceName: "mia-pc",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.enrolled, true);
|
||||||
|
assert.equal(r.body.user.username, "mia");
|
||||||
|
assert.ok(!JSON.stringify(r.body).includes(TOKEN));
|
||||||
|
const file = path.join(process.env.DASHBOARD_DATA_DIR, "runtime", "remote-client.json");
|
||||||
|
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||||
|
assert.ok(!fs.readFileSync(file, "utf8").includes("right-pass"), "the password is not kept");
|
||||||
|
const st = await local("GET", "/api/remote-client");
|
||||||
|
assert.ok(!JSON.stringify(st.body).includes(TOKEN));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the Company account", () => {
|
||||||
|
it("is Claude-only and becomes a --settings file pointing at the gateway", () => {
|
||||||
|
assert.throws(() => resolveProxy("codex", "agenthub.company"), { code: "EUNSUPPORTED" });
|
||||||
|
const profile = resolveProxy("claude", "agenthub.company");
|
||||||
|
assert.equal(profile.company, true);
|
||||||
|
const { argv, env } = proxyForSpawn("claude", profile);
|
||||||
|
assert.deepEqual(env, {}, "nothing secret goes through the pane env");
|
||||||
|
const file = argv[0].replace("--settings=", "");
|
||||||
|
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||||
|
const s = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
assert.equal(s.env.ANTHROPIC_BASE_URL, `${HUB}/api/remote/gw`);
|
||||||
|
assert.equal(s.env.ANTHROPIC_AUTH_TOKEN, TOKEN);
|
||||||
|
assert.equal(s.env.ENABLE_PROMPT_CACHING_1H, "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a resume stays on it", () => {
|
||||||
|
const { id } = makeSession({ company: true, lines: "" });
|
||||||
|
assert.equal(resumeProxy(id), "agenthub.company");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no profile may take its name", () => {
|
||||||
|
const proxies = require("../lib/ai-proxies");
|
||||||
|
const p = proxies.createProxy({
|
||||||
|
label: "Company",
|
||||||
|
baseUrl: "http://127.0.0.1:9",
|
||||||
|
apiKey: "k",
|
||||||
|
model: "m",
|
||||||
|
});
|
||||||
|
assert.notEqual(p.name, "agenthub.company");
|
||||||
|
// A profile made before the Company account existed keeps working as itself.
|
||||||
|
assert.equal(p.name, "company");
|
||||||
|
const resolved = resolveProxy("claude", "company");
|
||||||
|
assert.equal(resolved.company, undefined);
|
||||||
|
assert.equal(resolved.name, "company");
|
||||||
|
proxies.removeProxy(p.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the upload", () => {
|
||||||
|
it("sends whole lines of Company sessions only, secrets replaced, then only what is new", async () => {
|
||||||
|
hubSeen.uploads = [];
|
||||||
|
const secret = `sk-ant-api03-${"s".repeat(40)}`;
|
||||||
|
const line1 = JSON.stringify({ type: "user", message: { content: `my key is ${secret}` } });
|
||||||
|
const line2 = JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: { usage: { input_tokens: 5, output_tokens: 6 } },
|
||||||
|
});
|
||||||
|
const company = makeSession({ company: true, lines: `${line1}\n${line2}\n{"partial":` });
|
||||||
|
const own = makeSession({ company: false, lines: `${line1}\n` });
|
||||||
|
const r = await remote.uploadAll();
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
const mine = hubSeen.uploads.filter((u) => u.sessionId === company.id);
|
||||||
|
const sent = mine.filter((u) => u.files.length);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.equal(sent[0].more, true, "a chunk leaves the import to the round's close");
|
||||||
|
assert.equal(mine.length, 2);
|
||||||
|
assert.deepEqual(mine[1], { sessionId: company.id, files: [] }, "the close: import now");
|
||||||
|
const data = sent[0].files[0].data;
|
||||||
|
assert.ok(!data.includes(secret), "the key never leaves the machine");
|
||||||
|
assert.ok(data.includes("[redacted]"));
|
||||||
|
assert.ok(data.includes('"output_tokens":6'), "usage numbers survive");
|
||||||
|
assert.ok(!data.includes("partial"), "a line still being written waits");
|
||||||
|
assert.equal(sent[0].files[0].to, Buffer.byteLength(`${line1}\n${line2}\n`));
|
||||||
|
assert.equal(
|
||||||
|
hubSeen.uploads.filter((u) => u.sessionId === own.id).length,
|
||||||
|
0,
|
||||||
|
"an own-login session is never sent"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.appendFileSync(company.file, ' "done"}\n');
|
||||||
|
hubSeen.uploads = [];
|
||||||
|
await remote.uploadAll();
|
||||||
|
const next = hubSeen.uploads.filter((u) => u.sessionId === company.id && u.files.length);
|
||||||
|
assert.equal(next.length, 1);
|
||||||
|
assert.equal(next[0].files[0].from, Buffer.byteLength(`${line1}\n${line2}\n`));
|
||||||
|
assert.equal(next[0].files[0].data, '{"partial":"done"}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resyncs from where the hub stands after a 409", async () => {
|
||||||
|
const line = JSON.stringify({ type: "user", message: { content: "hi" } });
|
||||||
|
const s = makeSession({ company: true, lines: `${line}\n${line}\n` });
|
||||||
|
// The hub already holds the first line (an earlier answer got lost).
|
||||||
|
consumed.set(`${s.id}/${s.id}.jsonl`, Buffer.byteLength(`${line}\n`));
|
||||||
|
hubSeen.uploads = [];
|
||||||
|
await remote.uploadAll();
|
||||||
|
const tries = hubSeen.uploads.filter((u) => u.sessionId === s.id && u.files.length);
|
||||||
|
assert.equal(tries.length, 2);
|
||||||
|
assert.equal(tries[1].files[0].from, Buffer.byteLength(`${line}\n`));
|
||||||
|
assert.equal(consumed.get(`${s.id}/${s.id}.jsonl`), Buffer.byteLength(`${line}\n${line}\n`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the end once, when no pane holds the session any more", async () => {
|
||||||
|
const s = makeSession({ company: true, lines: '{"a":1}\n', runStatus: "exited" });
|
||||||
|
db.prepare("UPDATE sessions SET updated_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(s.id);
|
||||||
|
hubSeen.uploads = [];
|
||||||
|
await remote.uploadAll();
|
||||||
|
await remote.uploadAll();
|
||||||
|
const ends = hubSeen.uploads.filter((u) => u.sessionId === s.id && u.ended === true);
|
||||||
|
assert.equal(ends.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upload edges", () => {
|
||||||
|
it("sends a line too big for the hub as a placeholder and moves past it", async () => {
|
||||||
|
const big = JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
message: { content: "x".repeat(remote.MAX_LINE_BYTES + 10) },
|
||||||
|
});
|
||||||
|
const small = JSON.stringify({ type: "user", message: { content: "after" } });
|
||||||
|
const s = makeSession({ company: true, lines: `${big}\n${small}\n` });
|
||||||
|
hubSeen.uploads = [];
|
||||||
|
const r = await remote.uploadAll({ onlySession: s.id });
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
const data = hubSeen.uploads
|
||||||
|
.filter((u) => u.sessionId === s.id)
|
||||||
|
.map((u) => u.files.map((f) => f.data).join(""))
|
||||||
|
.join("");
|
||||||
|
assert.ok(data.length < 1024, "the big line is not sent");
|
||||||
|
assert.match(data, /"type":"agenthub-omitted","bytes":\d+/);
|
||||||
|
assert.match(data, /after/);
|
||||||
|
assert.equal(
|
||||||
|
consumed.get(`${s.id}/${s.id}.jsonl`),
|
||||||
|
Buffer.byteLength(`${big}\n${small}\n`),
|
||||||
|
"the hub's offset still covers the whole file"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers a session again after this machine logged in again (ENOSESSION)", async () => {
|
||||||
|
const s = makeSession({ company: true, lines: '{"a":1}\n' });
|
||||||
|
await new Promise((r) => setTimeout(r, 100)); // registerSoon's own registration
|
||||||
|
forgotten.add(s.id);
|
||||||
|
hubSeen.registered = [];
|
||||||
|
hubSeen.uploads = [];
|
||||||
|
const r = await remote.uploadAll({ onlySession: s.id });
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
assert.equal(hubSeen.registered.filter((b) => b.sessionId === s.id).length, 1);
|
||||||
|
assert.equal(hubSeen.registered[0].cwd, "/w/p");
|
||||||
|
assert.equal(consumed.get(`${s.id}/${s.id}.jsonl`), 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also registers again when only the end is left to send", async () => {
|
||||||
|
const s = makeSession({ company: true, lines: '{"a":1}\n', runStatus: "exited" });
|
||||||
|
db.prepare("UPDATE sessions SET updated_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(s.id);
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
consumed.set(`${s.id}/${s.id}.jsonl`, 8);
|
||||||
|
db.prepare("INSERT INTO remote_uploads (session_id, rel_path, sent) VALUES (?, ?, 8)").run(
|
||||||
|
s.id,
|
||||||
|
`${s.id}.jsonl`
|
||||||
|
);
|
||||||
|
forgotten.add(s.id);
|
||||||
|
hubSeen.uploads = [];
|
||||||
|
const r = await remote.uploadAll({ onlySession: s.id });
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
assert.ok(hubSeen.uploads.some((u) => u.sessionId === s.id && u.ended === true));
|
||||||
|
assert.equal(forgotten.has(s.id), false, "registered again");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops sending a session the hub already purged", async () => {
|
||||||
|
const s = makeSession({ company: true, lines: '{"a":1}\n' });
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
purged.add(s.id);
|
||||||
|
const r = await remote.uploadAll({ onlySession: s.id });
|
||||||
|
assert.deepEqual(r.errors, []);
|
||||||
|
assert.equal(remote.isCompany(s.id), false);
|
||||||
|
hubSeen.uploads = [];
|
||||||
|
await remote.uploadAll({ onlySession: s.id });
|
||||||
|
assert.equal(hubSeen.uploads.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a new session id in a Company pane", () => {
|
||||||
|
it("is registered from its hook; another pane's is not", async () => {
|
||||||
|
const { id } = makeSession({ company: true, lines: "" });
|
||||||
|
const run = `run-${id}`;
|
||||||
|
await new Promise((r) => setTimeout(r, 100)); // its own registration lands first
|
||||||
|
hubSeen.registered = [];
|
||||||
|
const fresh = crypto.randomUUID();
|
||||||
|
remote.onHookSession({ sessionId: fresh, runId: run, cwd: "/w/p" });
|
||||||
|
const other = makeSession({ company: false, lines: "" });
|
||||||
|
remote.onHookSession({ sessionId: crypto.randomUUID(), runId: `run-${other.id}`, cwd: "/w/p" });
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
assert.deepEqual(
|
||||||
|
hubSeen.registered.map((x) => x.sessionId),
|
||||||
|
[fresh]
|
||||||
|
);
|
||||||
|
assert.equal(remote.isCompany(fresh), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("logout", () => {
|
||||||
|
it("revokes on the hub and forgets the token; the Company account is then refused", async () => {
|
||||||
|
const r = await local("POST", "/api/remote-client/logout", {});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.revokedOnHub, true);
|
||||||
|
assert.equal(hubSeen.logout, 1);
|
||||||
|
assert.equal(remote.readState(), null);
|
||||||
|
assert.throws(() => resolveProxy("claude", "agenthub.company"), { code: "ENOHUB" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,941 @@
|
|||||||
|
/**
|
||||||
|
* @file remote-hub.test.js
|
||||||
|
* @description The hub half of the remote-clients architecture (server/hub/),
|
||||||
|
* in server mode: device enrollment by password (token shown once, stored as
|
||||||
|
* a hash, 90 days, revocable), session registration (UUID, one device per
|
||||||
|
* session, no hijack of a known session), the gateway (device token → pool
|
||||||
|
* account's OAuth token, never the reverse; only registered sessions, `warn`
|
||||||
|
* records an unmonitored one, `block` refuses; usage counted from SSE and
|
||||||
|
* JSON answers; one account per session), the hourly upload (offset protocol,
|
||||||
|
* fixed file shapes only, the SAME importer so tokens land, owner_id/source
|
||||||
|
* stamped, scope: a developer sees only their own), retention purge, the
|
||||||
|
* signed-in views (devices, revoke with the password again + audit, "who
|
||||||
|
* opened my sessions").
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const http = require("node:http");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "remote-hub-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
process.env.AGENTHUB_REMOTE_REGISTER_GRACE_MS = "300";
|
||||||
|
process.env.AGENTHUB_REMOTE_UNMONITORED_ALERT_MS = "250";
|
||||||
|
process.env.AGENTHUB_REMOTE_FLUSH_MS = "250";
|
||||||
|
|
||||||
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
|
let createApp;
|
||||||
|
let startServer;
|
||||||
|
let db;
|
||||||
|
let auth;
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
let upstream;
|
||||||
|
let seen = [];
|
||||||
|
const POOL_TOKEN = `sk-ant-oat01-${"p".repeat(40)}`;
|
||||||
|
const users = {};
|
||||||
|
|
||||||
|
const sid = () => crypto.randomUUID();
|
||||||
|
|
||||||
|
async function call(method, p, { token, cookie, body, headers = {} } = {}) {
|
||||||
|
const res = await fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
/* stream */
|
||||||
|
}
|
||||||
|
return { status: res.status, body: json, text, headers: res.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const res = await fetch(BASE + "/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return res.headers.get("set-cookie").split(";")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enroll(username, password, deviceName = "laptop") {
|
||||||
|
return call("POST", "/api/remote/device/login", { body: { username, password, deviceName } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcript(sessionId, cwd, { input = 10, output = 5, text = "hello" } = {}) {
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sessionId,
|
||||||
|
timestamp: "2026-11-04T01:00:00.000Z",
|
||||||
|
cwd,
|
||||||
|
message: { content: "do the thing" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
sessionId,
|
||||||
|
timestamp: "2026-11-04T01:00:01.000Z",
|
||||||
|
cwd,
|
||||||
|
message: {
|
||||||
|
id: `msg_${input}_${output}`,
|
||||||
|
model: "claude-sonnet-4-5-20250929",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
usage: {
|
||||||
|
input_tokens: input,
|
||||||
|
output_tokens: output,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.map((l) => JSON.stringify(l))
|
||||||
|
.join("\n") + "\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
upstream = http.createServer((req, res) => {
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (c) => (raw += c));
|
||||||
|
req.on("end", () => {
|
||||||
|
seen.push({ url: req.url, headers: req.headers, body: raw });
|
||||||
|
const asked = JSON.parse(raw || "{}");
|
||||||
|
if (asked.gzip) {
|
||||||
|
// As api.anthropic.com answers a client that accepts it: compressed.
|
||||||
|
const zlib = require("node:zlib");
|
||||||
|
const sse =
|
||||||
|
'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":11,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":1}}}\n\n' +
|
||||||
|
'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":22}}\n\n';
|
||||||
|
res.writeHead(200, { "content-type": "text/event-stream", "content-encoding": "gzip" });
|
||||||
|
res.end(zlib.gzipSync(sse));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (asked.stream) {
|
||||||
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||||
|
res.write(
|
||||||
|
'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":100,"cache_read_input_tokens":40,"cache_creation_input_tokens":7,"output_tokens":1}}}\n\n'
|
||||||
|
);
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
res.end(
|
||||||
|
'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":25}}\n\n'
|
||||||
|
),
|
||||||
|
20
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, usage: { input_tokens: 3, output_tokens: 4 } }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
||||||
|
process.env.AGENTHUB_REMOTE_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
|
||||||
|
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
({ createApp, startServer } = require("../index"));
|
||||||
|
({ db } = require("../db"));
|
||||||
|
auth = require("../lib/auth");
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
users.boss = await auth.createUser("boss", "admin-pass-1", "admin");
|
||||||
|
users.mia = await auth.createUser("mia", "member-pass-1", "developer");
|
||||||
|
users.tom = await auth.createUser("tom", "member-pass-2", "developer");
|
||||||
|
require("../lib/claude-pool").addAccount("team", POOL_TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
delete process.env.AGENTHUB_REMOTE_UPSTREAM;
|
||||||
|
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||||
|
server?.close();
|
||||||
|
upstream?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("device enrollment", () => {
|
||||||
|
it("gives a token once for the right password, stores only its hash, 90 days", async () => {
|
||||||
|
const r = await enroll("mia", "member-pass-1", "mia-laptop");
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.body.token, /^ahdev_[A-Za-z0-9_-]{43}$/);
|
||||||
|
assert.equal(r.body.user.username, "mia");
|
||||||
|
assert.equal(r.body.device.name, "mia-laptop");
|
||||||
|
const days = (Date.parse(r.body.device.expiresAt) - Date.now()) / 864e5;
|
||||||
|
assert.ok(days > 89 && days <= 90, `expires in ${days} days`);
|
||||||
|
const row = db.prepare("SELECT * FROM devices WHERE id = ?").get(r.body.device.id);
|
||||||
|
assert.notEqual(row.token_hash, r.body.token);
|
||||||
|
assert.ok(!JSON.stringify(row).includes(r.body.token));
|
||||||
|
const me = await call("GET", "/api/remote/device/me", { token: r.body.token });
|
||||||
|
assert.equal(me.status, 200);
|
||||||
|
assert.equal(me.body.user.username, "mia");
|
||||||
|
assert.ok(!JSON.stringify(me.body).includes("token_hash"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a wrong password and a disabled user", async () => {
|
||||||
|
assert.equal((await enroll("mia", "wrong-pass")).status, 401);
|
||||||
|
await auth.createUser("gone", "gone-pass-1", "developer");
|
||||||
|
auth.setDisabled("gone", true);
|
||||||
|
assert.equal((await enroll("gone", "gone-pass-1")).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a revoked, expired or disabled-user token opens nothing", async () => {
|
||||||
|
const r = await enroll("tom", "member-pass-2");
|
||||||
|
const t = r.body.token;
|
||||||
|
assert.equal((await call("GET", "/api/remote/device/me", { token: t })).status, 200);
|
||||||
|
db.prepare("UPDATE devices SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(
|
||||||
|
r.body.device.id
|
||||||
|
);
|
||||||
|
assert.equal((await call("GET", "/api/remote/device/me", { token: t })).status, 401);
|
||||||
|
const r2 = await enroll("tom", "member-pass-2");
|
||||||
|
assert.equal(
|
||||||
|
(await call("POST", "/api/remote/device/logout", { token: r2.body.token, body: {} })).status,
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call("GET", "/api/remote/device/me", { token: r2.body.token })).status,
|
||||||
|
401
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call("GET", "/api/remote/device/me", { token: "ahdev_" + "x".repeat(43) })).status,
|
||||||
|
401
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a device token is no login: cookie routes refuse it", async () => {
|
||||||
|
const r = await enroll("mia", "member-pass-1");
|
||||||
|
const res = await call("GET", "/api/sessions", { token: r.body.token });
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session registration", () => {
|
||||||
|
let mia;
|
||||||
|
let tom;
|
||||||
|
before(async () => {
|
||||||
|
mia = (await enroll("mia", "member-pass-1")).body.token;
|
||||||
|
tom = (await enroll("tom", "member-pass-2")).body.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a UUID + absolute folder, refuses anything else", async () => {
|
||||||
|
const id = sid();
|
||||||
|
const ok = await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, cwd: "/home/mia/proj" },
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.equal(ok.body.sessionId, id);
|
||||||
|
for (const body of [
|
||||||
|
{ sessionId: "not-a-uuid", cwd: "/x" },
|
||||||
|
{ sessionId: sid(), cwd: "relative/path" },
|
||||||
|
{ sessionId: sid(), cwd: "/x", tool: "codex" },
|
||||||
|
]) {
|
||||||
|
const r = await call("POST", "/api/remote/device/sessions", { token: mia, body });
|
||||||
|
assert.equal(r.status, 400, JSON.stringify(body));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("one session belongs to one device; a known local session cannot be claimed", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, cwd: "/a" },
|
||||||
|
});
|
||||||
|
const again = await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, cwd: "/a" },
|
||||||
|
});
|
||||||
|
assert.equal(again.status, 200, "re-registering (a resume) is fine");
|
||||||
|
const theft = await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: tom,
|
||||||
|
body: { sessionId: id, cwd: "/a" },
|
||||||
|
});
|
||||||
|
assert.equal(theft.status, 409);
|
||||||
|
// The same user enrolled again (a new device) resumes it: it moves along.
|
||||||
|
const mia2 = (await enroll("mia", "member-pass-1", "mia-new")).body.token;
|
||||||
|
const moved = await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia2,
|
||||||
|
body: { sessionId: id, cwd: "/a" },
|
||||||
|
});
|
||||||
|
assert.equal(moved.status, 200);
|
||||||
|
const back = await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, cwd: "/a" },
|
||||||
|
});
|
||||||
|
assert.equal(back.status, 200, "and back");
|
||||||
|
const local = sid();
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, 'local', 'active', '/w')"
|
||||||
|
).run(local);
|
||||||
|
const claim = await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: tom,
|
||||||
|
body: { sessionId: local, cwd: "/w" },
|
||||||
|
});
|
||||||
|
assert.equal(claim.status, 409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enrollment and registration edges", () => {
|
||||||
|
it("a temporary password (must change) cannot enroll a machine", async () => {
|
||||||
|
db.prepare("UPDATE users SET must_change_password = 1 WHERE username = 'mia'").run();
|
||||||
|
try {
|
||||||
|
const r = await enroll("mia", "member-pass-1", "temp-box");
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
assert.equal(r.body.error.code, "EPWCHANGE");
|
||||||
|
assert.equal(r.body.token, undefined);
|
||||||
|
} finally {
|
||||||
|
db.prepare("UPDATE users SET must_change_password = 0 WHERE username = 'mia'").run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a session keeps the folder it was first registered with", async () => {
|
||||||
|
const t = (await enroll("mia", "member-pass-1", "cwd-box")).body.token;
|
||||||
|
const id = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: t,
|
||||||
|
body: { sessionId: id, cwd: "/a" },
|
||||||
|
});
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: t,
|
||||||
|
body: { sessionId: id, cwd: "/b" },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT cwd FROM remote_sessions WHERE session_id = ?").get(id).cwd,
|
||||||
|
"/a"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a purged session cannot be registered again", async () => {
|
||||||
|
const t = (await enroll("mia", "member-pass-1", "purge-box")).body.token;
|
||||||
|
const id = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: t,
|
||||||
|
body: { sessionId: id, cwd: "/a" },
|
||||||
|
});
|
||||||
|
db.prepare("UPDATE remote_sessions SET purged_at = '2026-01-01' WHERE session_id = ?").run(id);
|
||||||
|
const r = await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: t,
|
||||||
|
body: { sessionId: id, cwd: "/a" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 410);
|
||||||
|
assert.equal(r.body.error.code, "EPURGED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the gateway", () => {
|
||||||
|
let token;
|
||||||
|
let deviceId;
|
||||||
|
before(async () => {
|
||||||
|
const r = await enroll("mia", "member-pass-1", "gw-box");
|
||||||
|
token = r.body.token;
|
||||||
|
deviceId = r.body.device.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const register = (id) =>
|
||||||
|
call("POST", "/api/remote/device/sessions", {
|
||||||
|
token,
|
||||||
|
body: { sessionId: id, cwd: "/home/mia/p" },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relays a registered session with the pool's OAuth token, never the device token", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await register(id);
|
||||||
|
seen = [];
|
||||||
|
const r = await call("POST", "/api/remote/gw/v1/messages?beta=true", {
|
||||||
|
token,
|
||||||
|
headers: {
|
||||||
|
"x-claude-code-session-id": id,
|
||||||
|
"anthropic-beta": "claude-code-20250219",
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
body: { model: "m", messages: [] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
const got = seen[0];
|
||||||
|
assert.equal(got.url, "/v1/messages?beta=true");
|
||||||
|
assert.equal(got.headers.authorization, `Bearer ${POOL_TOKEN}`);
|
||||||
|
assert.equal(got.headers["x-api-key"], undefined);
|
||||||
|
assert.equal(got.headers["anthropic-beta"], "claude-code-20250219,oauth-2025-04-20");
|
||||||
|
assert.ok(!JSON.stringify(got.headers).includes("ahdev_"));
|
||||||
|
assert.ok(!r.text.includes(POOL_TOKEN), "the pool token never reaches the client");
|
||||||
|
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||||
|
assert.equal(row.account, "team");
|
||||||
|
assert.equal(row.requests, 1);
|
||||||
|
assert.equal(row.gw_input_tokens, 3);
|
||||||
|
assert.equal(row.gw_output_tokens, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts usage from a streamed answer and passes the stream through", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await register(id);
|
||||||
|
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: { stream: true },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.text, /message_start[\s\S]*message_delta/);
|
||||||
|
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||||
|
assert.equal(row.gw_input_tokens, 100);
|
||||||
|
assert.equal(row.gw_cache_read_tokens, 40);
|
||||||
|
assert.equal(row.gw_cache_write_tokens, 7);
|
||||||
|
assert.equal(row.gw_output_tokens, 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts usage from a compressed answer and relays the bytes as they came", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await register(id);
|
||||||
|
const res = await fetch(`${BASE}/api/remote/gw/v1/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
"x-claude-code-session-id": id,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ gzip: true }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||||
|
assert.match(await res.text(), /message_delta/); // fetch inflates it for us
|
||||||
|
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||||
|
assert.equal(row.gw_input_tokens, 11);
|
||||||
|
assert.equal(row.gw_output_tokens, 22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits out a registration that is still on its way", async () => {
|
||||||
|
const id = sid();
|
||||||
|
const req = call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
setTimeout(() => register(id), 80);
|
||||||
|
const r = await req;
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT unmonitored FROM remote_sessions WHERE session_id = ?").get(id)
|
||||||
|
.unmonitored,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warn: an unregistered session is relayed and recorded as unmonitored", async () => {
|
||||||
|
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||||
|
const id = sid();
|
||||||
|
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||||
|
assert.equal(row.unmonitored, 1);
|
||||||
|
assert.equal(row.device_id, deviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warn: an unmonitored session raises the remote_unmonitored alert; a late registration clears it and alerts nobody", async () => {
|
||||||
|
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||||
|
const alerts = require("../lib/alerts");
|
||||||
|
assert.deepEqual(alerts.validateRuleConfig("remote_unmonitored", {}), {
|
||||||
|
ok: true,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO alert_rules (id, name, rule_type, config) VALUES ('ru1', 'outside', 'remote_unmonitored', '{}')"
|
||||||
|
).run();
|
||||||
|
alerts.invalidateRuleCache();
|
||||||
|
try {
|
||||||
|
const gw = (id) =>
|
||||||
|
call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
const outside = sid();
|
||||||
|
const late = sid();
|
||||||
|
assert.equal((await gw(outside)).status, 200);
|
||||||
|
assert.equal((await gw(late)).status, 200);
|
||||||
|
// `late` was only late: its registration arrives before the alert is due.
|
||||||
|
assert.equal((await register(late)).status, 200);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT unmonitored FROM remote_sessions WHERE session_id = ?").get(late)
|
||||||
|
.unmonitored,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 450));
|
||||||
|
const fired = db
|
||||||
|
.prepare("SELECT session_id, message FROM alert_events WHERE rule_id = 'ru1'")
|
||||||
|
.all();
|
||||||
|
const hit = fired.find((a) => a.session_id === outside);
|
||||||
|
assert.ok(hit, "the session nobody registered alerts");
|
||||||
|
assert.match(hit.message, /mia on gw-box/);
|
||||||
|
assert.ok(!fired.some((a) => a.session_id === late), "a late registration alerts nobody");
|
||||||
|
} finally {
|
||||||
|
db.prepare("DELETE FROM alert_rules WHERE id = 'ru1'").run();
|
||||||
|
alerts.invalidateRuleCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warn: a message with no session id is still refused, and a known session is never claimed", async () => {
|
||||||
|
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||||
|
seen = [];
|
||||||
|
const bare = await call("POST", "/api/remote/gw/v1/messages", { token, body: {} });
|
||||||
|
assert.equal(bare.status, 403);
|
||||||
|
assert.equal(bare.body.error.code, "EUNREGISTERED");
|
||||||
|
const hubOwn = sid();
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, 'hub pane', 'active', '/w')"
|
||||||
|
).run(hubOwn);
|
||||||
|
const claim = await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": hubOwn },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
assert.equal(claim.status, 409);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT 1 FROM remote_sessions WHERE session_id = ?").get(hubOwn),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(seen.length, 0, "nothing reached upstream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a user over their spend quota is refused (EQUOTA)", async () => {
|
||||||
|
const quota = require("../lib/user-quota");
|
||||||
|
const real = quota.assertSpawnAllowed;
|
||||||
|
const gw = require("../hub/remote-gateway");
|
||||||
|
quota.assertSpawnAllowed = () => {
|
||||||
|
throw Object.assign(new Error("over the daily limit"), { code: "EQUOTA" });
|
||||||
|
};
|
||||||
|
gw.__resetQuota();
|
||||||
|
try {
|
||||||
|
const id = sid();
|
||||||
|
await register(id);
|
||||||
|
seen = [];
|
||||||
|
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 429);
|
||||||
|
assert.equal(r.body.error.code, "EQUOTA");
|
||||||
|
assert.equal(seen.length, 0);
|
||||||
|
} finally {
|
||||||
|
quota.assertSpawnAllowed = real;
|
||||||
|
gw.__resetQuota();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a purged session is not relayed", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await register(id);
|
||||||
|
db.prepare("UPDATE remote_sessions SET purged_at = '2026-01-01' WHERE session_id = ?").run(id);
|
||||||
|
seen = [];
|
||||||
|
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 410);
|
||||||
|
assert.equal(seen.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("block: an unregistered session or a message with no session id is refused", async () => {
|
||||||
|
process.env.AGENTHUB_REMOTE_GATE = "block";
|
||||||
|
try {
|
||||||
|
seen = [];
|
||||||
|
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": sid() },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
assert.equal(r.body.error.code, "EUNREGISTERED");
|
||||||
|
const bare = await call("POST", "/api/remote/gw/v1/messages", { token, body: {} });
|
||||||
|
assert.equal(bare.status, 403);
|
||||||
|
assert.equal(seen.length, 0, "nothing reached upstream");
|
||||||
|
const id = sid();
|
||||||
|
await register(id);
|
||||||
|
const ok = await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
} finally {
|
||||||
|
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a bad token, another device's session and a path outside /v1/", async () => {
|
||||||
|
assert.equal(
|
||||||
|
(await call("POST", "/api/remote/gw/v1/messages", { token: "nope", body: {} })).status,
|
||||||
|
401
|
||||||
|
);
|
||||||
|
const id = sid();
|
||||||
|
await register(id);
|
||||||
|
const other = (await enroll("tom", "member-pass-2")).body.token;
|
||||||
|
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token: other,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
seen = [];
|
||||||
|
assert.equal((await call("GET", "/api/remote/gw/api/oauth/usage", { token })).status, 400);
|
||||||
|
// fetch folds the `..` away: the request never reaches the gateway at all.
|
||||||
|
assert.notEqual((await call("GET", "/api/remote/gw/v1/../../etc", { token })).status, 200);
|
||||||
|
assert.equal(seen.length, 0, "nothing reached upstream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a session on the account it started on", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await register(id);
|
||||||
|
db.prepare("UPDATE remote_sessions SET account = 'team' WHERE session_id = ?").run(id);
|
||||||
|
require("../lib/claude-pool").addAccount("aaa-first", `sk-ant-oat01-${"q".repeat(40)}`);
|
||||||
|
try {
|
||||||
|
seen = [];
|
||||||
|
await call("POST", "/api/remote/gw/v1/messages", {
|
||||||
|
token,
|
||||||
|
headers: { "x-claude-code-session-id": id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
assert.equal(seen[0].headers.authorization, `Bearer ${POOL_TOKEN}`);
|
||||||
|
} finally {
|
||||||
|
require("../lib/claude-pool").removeAccount("aaa-first");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the hourly upload", () => {
|
||||||
|
let mia;
|
||||||
|
let tom;
|
||||||
|
let miaCookie;
|
||||||
|
let tomCookie;
|
||||||
|
before(async () => {
|
||||||
|
mia = (await enroll("mia", "member-pass-1", "mia-pc")).body.token;
|
||||||
|
tom = (await enroll("tom", "member-pass-2", "tom-pc")).body.token;
|
||||||
|
miaCookie = await login("mia", "member-pass-1");
|
||||||
|
tomCookie = await login("tom", "member-pass-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends at the hub's offset, imports with the local importer, stamps owner and source", async () => {
|
||||||
|
const id = sid();
|
||||||
|
const cwd = "/home/mia/shop";
|
||||||
|
await call("POST", "/api/remote/device/sessions", { token: mia, body: { sessionId: id, cwd } });
|
||||||
|
const part1 = transcript(id, cwd);
|
||||||
|
const r1 = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: {
|
||||||
|
sessionId: id,
|
||||||
|
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(part1), data: part1 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(r1.status, 200, JSON.stringify(r1.body));
|
||||||
|
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||||
|
assert.ok(s, "the importer made the session row");
|
||||||
|
assert.equal(s.owner_id, users.mia.id);
|
||||||
|
assert.match(s.source, /^device:\d+$/);
|
||||||
|
const tok = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT SUM(input_tokens) AS i, SUM(output_tokens) AS o FROM token_usage WHERE session_id = ?"
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
assert.equal(tok.i, 10);
|
||||||
|
assert.equal(tok.o, 5);
|
||||||
|
|
||||||
|
// A wrong offset is told where the hub stands; nothing is written.
|
||||||
|
const bad = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, files: [{ path: `${id}.jsonl`, from: 0, to: 10, data: "x\n" }] },
|
||||||
|
});
|
||||||
|
assert.equal(bad.status, 409);
|
||||||
|
assert.equal(bad.body.error.expected.consumed, Buffer.byteLength(part1));
|
||||||
|
|
||||||
|
// Scope: mia sees it, tom does not, the admin does.
|
||||||
|
const own = await call("GET", "/api/sessions?limit=100", { cookie: miaCookie });
|
||||||
|
assert.ok(JSON.stringify(own.body).includes(id));
|
||||||
|
const other = await call("GET", "/api/sessions?limit=100", { cookie: tomCookie });
|
||||||
|
assert.ok(!JSON.stringify(other.body).includes(id));
|
||||||
|
assert.equal((await call("GET", `/api/sessions/${id}`, { cookie: tomCookie })).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chunks marked `more` are imported once: by the round's closing request, or after FLUSH_MS", async () => {
|
||||||
|
const tokens = (id) =>
|
||||||
|
db.prepare("SELECT SUM(output_tokens) AS o FROM token_usage WHERE session_id = ?").get(id).o;
|
||||||
|
const cwd = "/home/mia/chunks";
|
||||||
|
const up = (id, body) =>
|
||||||
|
call("POST", "/api/remote/device/upload", { token: mia, body: { sessionId: id, ...body } });
|
||||||
|
|
||||||
|
const id = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", { token: mia, body: { sessionId: id, cwd } });
|
||||||
|
const [l1, l2] = transcript(id, cwd).split(/(?<=\n)/);
|
||||||
|
const a = await up(id, {
|
||||||
|
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(l1), data: l1 }],
|
||||||
|
more: true,
|
||||||
|
});
|
||||||
|
assert.equal(a.status, 200, JSON.stringify(a.body));
|
||||||
|
const from = Buffer.byteLength(l1);
|
||||||
|
await up(id, {
|
||||||
|
files: [{ path: `${id}.jsonl`, from, to: from + Buffer.byteLength(l2), data: l2 }],
|
||||||
|
more: true,
|
||||||
|
});
|
||||||
|
assert.equal(tokens(id), null, "nothing imported while the round goes on");
|
||||||
|
const close = await up(id, { files: [] });
|
||||||
|
assert.equal(close.status, 200);
|
||||||
|
assert.equal(close.body.imported, true);
|
||||||
|
assert.equal(tokens(id), 5);
|
||||||
|
|
||||||
|
// A round that stopped half-way is imported FLUSH_MS later.
|
||||||
|
const id2 = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", { token: mia, body: { sessionId: id2, cwd } });
|
||||||
|
const t2 = transcript(id2, cwd, { output: 7 });
|
||||||
|
await up(id2, {
|
||||||
|
files: [{ path: `${id2}.jsonl`, from: 0, to: Buffer.byteLength(t2), data: t2 }],
|
||||||
|
more: true,
|
||||||
|
});
|
||||||
|
assert.equal(tokens(id2), null);
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
assert.equal(tokens(id2), 7);
|
||||||
|
assert.deepEqual(require("../hub/remote-ingest").__pending(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a session moved to the user's new device keeps its copy and offsets", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, cwd: "/m" },
|
||||||
|
});
|
||||||
|
const t1 = transcript(id, "/m", { input: 1, output: 2 });
|
||||||
|
const first = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: {
|
||||||
|
sessionId: id,
|
||||||
|
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(t1), data: t1 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(first.status, 200, JSON.stringify(first.body));
|
||||||
|
const newer = (await enroll("mia", "member-pass-1", "mia-reinstalled")).body;
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: newer.token,
|
||||||
|
body: { sessionId: id, cwd: "/m" },
|
||||||
|
});
|
||||||
|
const t2 = transcript(id, "/m", { input: 30, output: 40 }).replace(
|
||||||
|
/2026-11-04T01:00/g,
|
||||||
|
"2026-11-04T02:00"
|
||||||
|
);
|
||||||
|
const r = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: newer.token,
|
||||||
|
body: {
|
||||||
|
sessionId: id,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: `${id}.jsonl`,
|
||||||
|
from: Buffer.byteLength(t1),
|
||||||
|
to: Buffer.byteLength(t1 + t2),
|
||||||
|
data: t2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||||
|
const old = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, files: [] },
|
||||||
|
});
|
||||||
|
assert.equal(old.status, 404, "the old device no longer holds it");
|
||||||
|
const s = db.prepare("SELECT source, transcript_path FROM sessions WHERE id = ?").get(id);
|
||||||
|
assert.equal(s.source, `device:${newer.device.id}`);
|
||||||
|
assert.ok(s.transcript_path.includes(`/d${newer.device.id}/`), s.transcript_path);
|
||||||
|
assert.equal(fs.readFileSync(s.transcript_path, "utf8"), t1 + t2, "one file, both parts");
|
||||||
|
const tok = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT SUM(input_tokens) AS i, SUM(output_tokens) AS o FROM token_usage WHERE session_id = ?"
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
assert.equal(tok.i, 31);
|
||||||
|
assert.equal(tok.o, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes only the session's own file shapes, from its own device", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, cwd: "/m" },
|
||||||
|
});
|
||||||
|
for (const p of [
|
||||||
|
"../../etc/passwd",
|
||||||
|
`${id}/../x.jsonl`,
|
||||||
|
`other.jsonl`,
|
||||||
|
`${id}/subagents/agent-a/../../x.jsonl`,
|
||||||
|
`${id}/notes.txt`,
|
||||||
|
]) {
|
||||||
|
const r = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, files: [{ path: p, from: 0, to: 2, data: "{}\n" }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400, p);
|
||||||
|
}
|
||||||
|
const sub = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: {
|
||||||
|
sessionId: id,
|
||||||
|
files: [{ path: `${id}/subagents/agent-abc.jsonl`, from: 0, to: 3, data: "{}\n" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(sub.status, 200);
|
||||||
|
const foreign = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: tom,
|
||||||
|
body: { sessionId: id, files: [] },
|
||||||
|
});
|
||||||
|
assert.equal(foreign.status, 404);
|
||||||
|
const partial = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, files: [{ path: `${id}.jsonl`, from: 0, to: 3, data: "abc" }] },
|
||||||
|
});
|
||||||
|
assert.equal(partial.status, 400, "half a line is refused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges conversations past retention and keeps the tokens", async () => {
|
||||||
|
const id = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, cwd: "/m" },
|
||||||
|
});
|
||||||
|
const t = transcript(id, "/m", { input: 7, output: 9 });
|
||||||
|
await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: {
|
||||||
|
sessionId: id,
|
||||||
|
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(t), data: t }],
|
||||||
|
ended: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n > 0);
|
||||||
|
const ingest = require("../hub/remote-ingest");
|
||||||
|
const n = ingest.purgeOld({ days: 30, now: Date.now() + 31 * 864e5 });
|
||||||
|
assert.ok(n >= 1);
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n, 0);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT SUM(output_tokens) AS o FROM token_usage WHERE session_id = ?").get(id).o,
|
||||||
|
9
|
||||||
|
);
|
||||||
|
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||||
|
assert.ok(row.purged_at);
|
||||||
|
assert.ok(row.ended_at);
|
||||||
|
const r = await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, files: [] },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 410);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signed-in views", () => {
|
||||||
|
it("a developer lists only their own devices and revokes one with their password", async () => {
|
||||||
|
const d = (await enroll("mia", "member-pass-1", "to-revoke")).body;
|
||||||
|
const miaCookie = await login("mia", "member-pass-1");
|
||||||
|
const tomCookie = await login("tom", "member-pass-2");
|
||||||
|
const mine = await call("GET", "/api/remote/devices", { cookie: miaCookie });
|
||||||
|
assert.ok(mine.body.devices.every((x) => x.userId === users.mia.id));
|
||||||
|
const peek = await call("GET", `/api/remote/devices?user=${users.mia.id}`, {
|
||||||
|
cookie: tomCookie,
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
peek.body.devices.every((x) => x.userId === users.tom.id),
|
||||||
|
"?user= is ignored for a developer"
|
||||||
|
);
|
||||||
|
const steal = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||||
|
cookie: tomCookie,
|
||||||
|
body: { password: "member-pass-2" },
|
||||||
|
});
|
||||||
|
assert.equal(steal.status, 404);
|
||||||
|
const wrong = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||||
|
cookie: miaCookie,
|
||||||
|
body: { password: "nope" },
|
||||||
|
});
|
||||||
|
assert.equal(wrong.status, 403);
|
||||||
|
const ok = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||||
|
cookie: miaCookie,
|
||||||
|
body: { password: "member-pass-1" },
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.ok(ok.body.device.revokedAt);
|
||||||
|
assert.equal((await call("GET", "/api/remote/device/me", { token: d.token })).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin sees a user's devices and remote sessions and revokes with audit", async () => {
|
||||||
|
const d = (await enroll("tom", "member-pass-2", "tom-revoked-by-admin")).body;
|
||||||
|
const boss = await login("boss", "admin-pass-1");
|
||||||
|
const list = await call("GET", `/api/remote/devices?user=${users.tom.id}`, { cookie: boss });
|
||||||
|
assert.ok(list.body.devices.some((x) => x.id === d.device.id));
|
||||||
|
assert.ok(list.body.devices.every((x) => !("token_hash" in x)));
|
||||||
|
const r = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { password: "admin-pass-1" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const a = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM audit_log WHERE user_id = ? AND owner_id = ? AND path LIKE 'remote device revoked%'"
|
||||||
|
)
|
||||||
|
.get(users.boss.id, users.tom.id);
|
||||||
|
assert.ok(a);
|
||||||
|
const sessions = await call("GET", `/api/remote/sessions?user=${users.mia.id}`, {
|
||||||
|
cookie: boss,
|
||||||
|
});
|
||||||
|
assert.equal(sessions.status, 200);
|
||||||
|
assert.ok(sessions.body.sessions.length > 0);
|
||||||
|
assert.ok(sessions.body.sessions.every((s) => s.userId === users.mia.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a user sees who opened their sessions", async () => {
|
||||||
|
const mia = (await enroll("mia", "member-pass-1")).body.token;
|
||||||
|
const id = sid();
|
||||||
|
await call("POST", "/api/remote/device/sessions", {
|
||||||
|
token: mia,
|
||||||
|
body: { sessionId: id, cwd: "/m" },
|
||||||
|
});
|
||||||
|
const t = transcript(id, "/m");
|
||||||
|
await call("POST", "/api/remote/device/upload", {
|
||||||
|
token: mia,
|
||||||
|
body: {
|
||||||
|
sessionId: id,
|
||||||
|
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(t), data: t }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const boss = await login("boss", "admin-pass-1");
|
||||||
|
assert.equal((await call("GET", `/api/sessions/${id}`, { cookie: boss })).status, 200);
|
||||||
|
const miaCookie = await login("mia", "member-pass-1");
|
||||||
|
const log = await call("GET", "/api/remote/audit", { cookie: miaCookie });
|
||||||
|
assert.equal(log.status, 200);
|
||||||
|
assert.ok(log.body.entries.some((e) => e.sessionId === id && e.reader === "boss"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the users page counts a remote session's spend for its owner", async () => {
|
||||||
|
const boss = await login("boss", "admin-pass-1");
|
||||||
|
const r = await call("GET", `/api/users/${users.mia.id}/details`, { cookie: boss });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.ok(r.body.sessions.total >= 2, JSON.stringify(r.body.sessions));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -247,6 +247,15 @@ describe("broadcastAudience", () => {
|
|||||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
|
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tells an artifact's change to its owner and the people it is shared with only", () => {
|
||||||
|
const d = { id: 5, ownerId: 1, shared: false, recipients: [2] };
|
||||||
|
assert.equal(reaches("artifact_changed", d, m1), true);
|
||||||
|
assert.equal(reaches("artifact_changed", d, m2), true);
|
||||||
|
assert.equal(reaches("artifact_changed", { ...d, recipients: [] }, m2), false);
|
||||||
|
assert.equal(reaches("artifact_changed", { ...d, recipients: undefined }, m2), false);
|
||||||
|
assert.equal(reaches("artifact_changed", { ...d, shared: true, recipients: [] }, m2), true);
|
||||||
|
});
|
||||||
|
|
||||||
it("routes workspace messages by owner and keeps unknown types from members", () => {
|
it("routes workspace messages by owner and keeps unknown types from members", () => {
|
||||||
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m1), true);
|
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m1), true);
|
||||||
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m2), false);
|
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m2), false);
|
||||||
|
|||||||
@@ -192,3 +192,31 @@ describe("a member's new project folder", () => {
|
|||||||
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
|
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the pet chat's workspace for a member", () => {
|
||||||
|
it("is <their folder>/sagi, made as them — never the host's home (EFOLDER)", async () => {
|
||||||
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||||
|
const sudo = fakeSudo();
|
||||||
|
runAs.__setExecImpl(sudo.exec);
|
||||||
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "mia", password: "member-pass-1" }),
|
||||||
|
});
|
||||||
|
const cm = login.headers.get("set-cookie").split(";")[0];
|
||||||
|
const ensure = () =>
|
||||||
|
fetch(`${BASE}/api/lanes/ensure`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", cookie: cm },
|
||||||
|
body: JSON.stringify({ title: "Sagi" }),
|
||||||
|
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||||
|
const first = await ensure();
|
||||||
|
assert.equal(first.status, 201, JSON.stringify(first.body));
|
||||||
|
const want = fs.realpathSync(path.join(USERS, "mia", "sagi"));
|
||||||
|
assert.equal(first.body.lane.cwd, want);
|
||||||
|
const mk = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
|
||||||
|
assert.ok(mk && mk.includes("ah-mia") && mk.includes("mkdir"));
|
||||||
|
const again = await ensure();
|
||||||
|
assert.equal(again.body.lane.id, first.body.lane.id, "the same workspace next time");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -408,6 +408,17 @@ describe("our own tmux server: bounded and version-matched", () => {
|
|||||||
assert.equal(calls, after);
|
assert.equal(calls, after);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"forgets a cached server binary that is gone and runs PATH's tmux",
|
||||||
|
{ skip: process.platform !== "linux" || !tmux.isTmuxAvailable() },
|
||||||
|
() => {
|
||||||
|
// The last pane stopped: the cached /proc/<pid>/exe no longer exists.
|
||||||
|
tmux.__setHostBin("/proc/999999999/exe");
|
||||||
|
tmux.serverVersion(); // no server in the test's TMUX_TMPDIR: null, not ENOENT
|
||||||
|
assert.equal(tmux.__hostBin(), "tmux");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it("finds the binary of the server listening on our socket from /proc alone", () => {
|
it("finds the binary of the server listening on our socket from /proc alone", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tmux-proc-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tmux-proc-"));
|
||||||
const sock = "/tmp/tmux-1000/default";
|
const sock = "/tmp/tmux-1000/default";
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* @file Regression test: a turn the transcript says has ended lands the main
|
||||||
|
* agent in Waiting even when Claude Code never sends the Stop hook (it skips
|
||||||
|
* it in some turns — only the prompt-suggestion helper's SubagentStop
|
||||||
|
* arrives). Before, the agent stayed `working` until the idle Notification a
|
||||||
|
* minute later and the chat showed Stop on a finished answer. A turn end OLDER
|
||||||
|
* than the last prompt, or one read on new work (UserPromptSubmit), must not.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
const STAMP = `turn-end-${Date.now()}-${process.pid}`;
|
||||||
|
const TMP = path.join(os.tmpdir(), STAMP);
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { stmts } = require("../db");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
|
||||||
|
function post(body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL("/api/hooks/event", BASE);
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
const r = http.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname,
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume();
|
||||||
|
res.on("end", () => resolve(res.statusCode));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
r.on("error", reject);
|
||||||
|
r.write(payload);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = (o) => JSON.stringify(o) + "\n";
|
||||||
|
const user = (text) => ({ type: "user", message: { role: "user", content: text } });
|
||||||
|
const reply = (id) => ({
|
||||||
|
type: "assistant",
|
||||||
|
message: { id, model: "claude-sonnet-5", content: [{ type: "text", text: "ok" }], usage: { input_tokens: 1, output_tokens: 1 } },
|
||||||
|
});
|
||||||
|
const turnEnd = (ts) => ({ type: "system", subtype: "turn_duration", durationMs: 900, timestamp: ts });
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(TMP, { recursive: true });
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a finished turn without a Stop hook", () => {
|
||||||
|
it("lands in Waiting on the next hook that reads the turn's end", async () => {
|
||||||
|
const cwd = path.join(TMP, "work");
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
const sid = "12121212-3434-5656-7878-909090909090";
|
||||||
|
const tpath = path.join(TMP, `${sid}.jsonl`);
|
||||||
|
const event = (extra = {}) => ({ session_id: sid, cwd, transcript_path: tpath, ...extra });
|
||||||
|
const main = () => stmts.getAgent.get(`${sid}-main`);
|
||||||
|
|
||||||
|
fs.writeFileSync(tpath, line(user("hello")));
|
||||||
|
assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "hello" }) }), 200);
|
||||||
|
assert.equal(main().status, "working");
|
||||||
|
|
||||||
|
// The turn ends; Claude Code writes turn_duration but sends no Stop —
|
||||||
|
// only a helper agent's SubagentStop arrives.
|
||||||
|
fs.appendFileSync(tpath, line(reply("m1")) + line(turnEnd(new Date().toISOString())));
|
||||||
|
assert.equal(
|
||||||
|
await post({ hook_type: "SubagentStop", data: event({ agent_id: "a1", agent_type: "" }) }),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert.equal(main().status, "waiting");
|
||||||
|
assert.equal(stmts.getSession.get(sid).awaiting_reason, "stop");
|
||||||
|
|
||||||
|
// A new prompt: that turn's old end must not end the new one.
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
fs.appendFileSync(tpath, line(user("again")));
|
||||||
|
assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "again" }) }), 200);
|
||||||
|
assert.equal(main().status, "working");
|
||||||
|
assert.equal(
|
||||||
|
await post({ hook_type: "SubagentStop", data: event({ agent_id: "a2", agent_type: "" }) }),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert.equal(main().status, "working", "no turn end newer than the prompt yet");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ const { execFileSync } = require("child_process");
|
|||||||
const {
|
const {
|
||||||
getUpdatesStatus,
|
getUpdatesStatus,
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
|
restoreNpmLockfiles,
|
||||||
compareVersions,
|
compareVersions,
|
||||||
releaseApiUrl,
|
releaseApiUrl,
|
||||||
detectPluginInstall,
|
detectPluginInstall,
|
||||||
@@ -512,3 +513,48 @@ describe("applyUpdate — plugin cache is Claude Code's to replace", () => {
|
|||||||
assert.equal(git(repo, ["rev-parse", "HEAD"]), headBefore, "HEAD must be untouched");
|
assert.equal(git(repo, ["rev-parse", "HEAD"]), headBefore, "HEAD must be untouched");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("restoreNpmLockfiles — npm's rewrite never blocks an update", () => {
|
||||||
|
const git = (cwd, ...args) =>
|
||||||
|
execFileSync("git", args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: "t",
|
||||||
|
GIT_AUTHOR_EMAIL: "t@t",
|
||||||
|
GIT_COMMITTER_NAME: "t",
|
||||||
|
GIT_COMMITTER_EMAIL: "t@t",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let repo;
|
||||||
|
before(() => {
|
||||||
|
repo = fs.mkdtempSync(path.join(os.tmpdir(), "lockfiles-"));
|
||||||
|
git(repo, "init", "-q");
|
||||||
|
fs.mkdirSync(path.join(repo, "client"));
|
||||||
|
fs.writeFileSync(path.join(repo, "package-lock.json"), "{}\n");
|
||||||
|
fs.writeFileSync(path.join(repo, "client", "package-lock.json"), "{}\n");
|
||||||
|
fs.writeFileSync(path.join(repo, "app.js"), "v1\n");
|
||||||
|
git(repo, "add", ".");
|
||||||
|
git(repo, "commit", "-qm", "init");
|
||||||
|
});
|
||||||
|
after(() => fs.rmSync(repo, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("puts lockfiles npm rewrote back, and only when nothing else changed", async () => {
|
||||||
|
const lock = path.join(repo, "client", "package-lock.json");
|
||||||
|
fs.writeFileSync(lock, '{"npm":"rewrote"}\n');
|
||||||
|
assert.deepEqual(await restoreNpmLockfiles(repo), ["client/package-lock.json"]);
|
||||||
|
assert.equal(fs.readFileSync(lock, "utf8"), "{}\n");
|
||||||
|
|
||||||
|
// A real change beside it: nothing is touched, the merge refuses as before.
|
||||||
|
fs.writeFileSync(lock, '{"npm":"rewrote"}\n');
|
||||||
|
fs.writeFileSync(path.join(repo, "app.js"), "my edit\n");
|
||||||
|
assert.deepEqual(await restoreNpmLockfiles(repo), []);
|
||||||
|
assert.equal(fs.readFileSync(lock, "utf8"), '{"npm":"rewrote"}\n');
|
||||||
|
assert.equal(fs.readFileSync(path.join(repo, "app.js"), "utf8"), "my edit\n");
|
||||||
|
|
||||||
|
// A clean tree: nothing to do.
|
||||||
|
git(repo, "checkout", "--", ".");
|
||||||
|
assert.deepEqual(await restoreNpmLockfiles(repo), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user