Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| decf176b4b | |||
| dea74b777e | |||
| 18e574a765 | |||
| 879a6bb02d | |||
| 1a365a45bd | |||
| 54001585e2 | |||
| 0bf552b793 | |||
| 87ba935c6e | |||
| 045e46e111 | |||
| e7573f13d9 | |||
| 9d28807f2c | |||
| e60ebe468b | |||
| 26977780cc | |||
| e06472ecd9 | |||
| ab27629139 | |||
| 3b1fdfac51 | |||
| 087c1ddb5d | |||
| 890cd08f57 | |||
| aabc95768c | |||
| b34b201bd1 | |||
| c356cf96a1 | |||
| 2a31537b80 | |||
| 0cd853e330 | |||
| 59533b997b | |||
| e21120d51d | |||
| c946b73d11 | |||
| 3daf7ed4cb | |||
| f9869ada6d | |||
| 0fc436dc0c | |||
| cd25791160 | |||
| 2a83551eee | |||
| d0889d5250 | |||
| 6958a7b4f6 | |||
| aa7f1b1c60 | |||
| 70d7cf30c2 | |||
| 5912e9e613 | |||
| 497faae3bf | |||
| e6ce8009b1 | |||
| 705353b0da | |||
| 0792c88612 | |||
| a3d1dce2ca | |||
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda | |||
| 1dd2521183 | |||
| 21a042dc2b | |||
| 85edc88a6d | |||
| c229b4b79b | |||
| 9721941962 | |||
| b777e08371 | |||
| d0b4f8aaf6 | |||
| 916505fbac | |||
| 75a4693f27 |
@@ -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>
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export function usePermissions() {
|
|||||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||||
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||||
|
// Owner-only actions on a workspace (rename, reset, remove, share): its
|
||||||
|
// owner or an admin. A workspace shared with this user is not theirs.
|
||||||
|
ownsLane: (lane?: { owner_id?: number | null } | null) =>
|
||||||
|
isAdmin || (!!user && lane?.owner_id === user.id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file NeedsYouStrip.tsx
|
* @file NeedsYouStrip.tsx
|
||||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
* @description The Dashboard's pinned "Needs you" strip: every active session
|
||||||
* blocked on the user (`awaiting_input_since`), each linking straight to its
|
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
|
||||||
|
* admin's lists no other user's), each linking straight to its
|
||||||
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
|||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ awaiting: true, limit: 20 })
|
.list({ awaiting: true, limit: 20, mine: true })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setSessions(r.sessions);
|
setSessions(r.sessions);
|
||||||
setTotal(r.total);
|
setTotal(r.total);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* @file PdfView.tsx
|
||||||
|
* @description A PDF drawn by pdf.js onto canvases, page after page — the
|
||||||
|
* Artifacts preview's PDF viewer. The browser's own viewer cannot run inside
|
||||||
|
* the sandbox the preview keeps (it blocks plugins), and opening a shared PDF
|
||||||
|
* on the dashboard's origin would be the risk the sandbox exists for; pdf.js
|
||||||
|
* only DRAWS the file (no scripting sandbox loaded, no XFA, no links
|
||||||
|
* followed), so nothing in it runs. pdf.js and its worker are loaded the
|
||||||
|
* first time a PDF is opened (a separate chunk), never with the dashboard.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
/** Pages drawn at most: a 500-page export would otherwise hold every canvas. */
|
||||||
|
const MAX_PAGES = 200;
|
||||||
|
const MAX_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
async function loadPdfJs() {
|
||||||
|
// The legacy build: the modern one needs `Promise.try` and friends, which a
|
||||||
|
// browser a year old lacks — the PDF would then just fail to open.
|
||||||
|
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||||
|
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
return pdfjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfView({ url, title }: { url: string; title: string }) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
const [state, setState] = useState<"loading" | "ready" | "failed">("loading");
|
||||||
|
const [pages, setPages] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
let destroy: (() => void) | null = null;
|
||||||
|
setState("loading");
|
||||||
|
(async () => {
|
||||||
|
const res = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const data = new Uint8Array(await res.arrayBuffer());
|
||||||
|
if (data.byteLength > MAX_BYTES) throw new Error("too big");
|
||||||
|
const pdfjs = await loadPdfJs();
|
||||||
|
const task = pdfjs.getDocument({
|
||||||
|
data,
|
||||||
|
enableXfa: false,
|
||||||
|
disableAutoFetch: true,
|
||||||
|
});
|
||||||
|
destroy = () => void task.destroy();
|
||||||
|
const doc = await task.promise;
|
||||||
|
if (!live || !box.current) return;
|
||||||
|
setPages(doc.numPages);
|
||||||
|
const width = Math.max(320, box.current.clientWidth - 32);
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
box.current.replaceChildren();
|
||||||
|
for (let n = 1; n <= Math.min(doc.numPages, MAX_PAGES); n++) {
|
||||||
|
const page = await doc.getPage(n);
|
||||||
|
if (!live || !box.current) return;
|
||||||
|
const base = page.getViewport({ scale: 1 });
|
||||||
|
const viewport = page.getViewport({ scale: (width / base.width) * ratio });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.floor(viewport.width);
|
||||||
|
canvas.height = Math.floor(viewport.height);
|
||||||
|
canvas.style.width = `${Math.floor(viewport.width / ratio)}px`;
|
||||||
|
canvas.className = "mx-auto mb-3 block bg-white shadow";
|
||||||
|
canvas.setAttribute("aria-label", t("pdf.page", { n }));
|
||||||
|
box.current.appendChild(canvas);
|
||||||
|
await page.render({ canvas, viewport }).promise;
|
||||||
|
if (n === 1 && live) setState("ready");
|
||||||
|
}
|
||||||
|
})().catch(() => live && setState("failed"));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
destroy?.();
|
||||||
|
};
|
||||||
|
}, [url, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full overflow-auto bg-surface-2 p-4" data-testid="pdf-view">
|
||||||
|
{state === "loading" && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-fg-muted">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" aria-label={t("loading")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state === "failed" && (
|
||||||
|
<p className="p-6 text-center text-sm text-fg-secondary">{t("pdf.failed")}</p>
|
||||||
|
)}
|
||||||
|
<div ref={box} role="document" aria-label={title} />
|
||||||
|
{pages > MAX_PAGES && (
|
||||||
|
<p className="py-2 text-center text-xs text-fg-muted">
|
||||||
|
{t("pdf.more", { shown: MAX_PAGES, total: pages })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,595 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file PetChat.tsx
|
|
||||||
* @description The pet's chat window — its whole UI: clicking the pet opens
|
|
||||||
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
|
|
||||||
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
|
|
||||||
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
|
|
||||||
* who are you…) is answered right here; anything else — and every message once
|
|
||||||
* a chat runs — goes to the AI: the first message starts a
|
|
||||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
|
||||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
|
||||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
|
||||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
|
||||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
|
||||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
|
||||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
|
||||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
|
||||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
|
||||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
|
||||||
* reopening the window continues it; **New chat** STOPS that session (kills
|
|
||||||
* its pane and archives it, asking first only mid-reply), **Open in workspace**
|
|
||||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
|
||||||
* is gone, the next message starts a new one.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
|
||||||
import { api } from "../../lib/api";
|
|
||||||
import type { TranscriptMessage } from "../../lib/types";
|
|
||||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
|
||||||
import {
|
|
||||||
answerRunMenu,
|
|
||||||
chooseRunOption,
|
|
||||||
interruptRun,
|
|
||||||
readRunMenu,
|
|
||||||
stepRunMenu,
|
|
||||||
submitToRun,
|
|
||||||
type PaneMenu,
|
|
||||||
} from "../../lib/panePaste";
|
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
|
||||||
import { toast } from "../../lib/toast";
|
|
||||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
|
||||||
import { TerminalView } from "../run/TerminalView";
|
|
||||||
import { PaneMenuCard } from "../run/PaneChat";
|
|
||||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
|
||||||
|
|
||||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
|
||||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
|
||||||
const CHAT_KEY = "agenthub.pet.chat";
|
|
||||||
const POLL_MS = 1500;
|
|
||||||
const MENU_POLL_MS = 700;
|
|
||||||
// A follow-up waits this long for the off-screen pane to attach.
|
|
||||||
const SEND_TRIES = 20;
|
|
||||||
const SEND_RETRY_MS = 250;
|
|
||||||
|
|
||||||
/** The session the chat is talking to. */
|
|
||||||
export interface PetChatRef {
|
|
||||||
laneId: number;
|
|
||||||
runId: string;
|
|
||||||
tool: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readPetChat(): PetChatRef | null {
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
|
||||||
if (
|
|
||||||
v &&
|
|
||||||
typeof v.laneId === "number" &&
|
|
||||||
typeof v.runId === "string" &&
|
|
||||||
typeof v.tool === "string"
|
|
||||||
)
|
|
||||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
|
||||||
} catch {
|
|
||||||
/* unreadable: no chat */
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writePetChat(ref: PetChatRef | null) {
|
|
||||||
try {
|
|
||||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
|
||||||
else localStorage.removeItem(CHAT_KEY);
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
|
||||||
export function petChatTool(): string | null {
|
|
||||||
const tool = readDefaultTool() ?? "claude";
|
|
||||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Line =
|
|
||||||
| { key: string; role: "user" | "assistant"; text: string }
|
|
||||||
| { key: string; role: "tools"; names: string[] };
|
|
||||||
|
|
||||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
|
||||||
* `<system-reminder>`…) are not something the user said. */
|
|
||||||
const humanText = (m: TranscriptMessage) =>
|
|
||||||
m.content
|
|
||||||
.filter((c) => c.type === "text" && c.text)
|
|
||||||
.map((c) => c.text!.trim())
|
|
||||||
.filter((t) => t && !t.startsWith("<"))
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
|
||||||
* each run of tool calls between them folded into one chip. */
|
|
||||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
|
||||||
const out: Line[] = [];
|
|
||||||
messages.forEach((m, i) => {
|
|
||||||
const sender = m.sender ?? m.type;
|
|
||||||
if (sender === "user") {
|
|
||||||
const text = humanText(m);
|
|
||||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (m.type !== "assistant") return;
|
|
||||||
m.content.forEach((c, j) => {
|
|
||||||
if (c.type === "text" && c.text?.trim()) {
|
|
||||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
|
||||||
} else if (c.type === "tool_use" && c.name) {
|
|
||||||
const last = out[out.length - 1];
|
|
||||||
if (last?.role === "tools") last.names.push(c.name);
|
|
||||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
|
||||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const m = messages[i]!;
|
|
||||||
const sender = m.sender ?? m.type;
|
|
||||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
|
||||||
if (sender === "user" && humanText(m)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Read ×2 · Bash" */
|
|
||||||
function toolSummary(names: string[]): string {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
|
||||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the container made of a message: an answer the pet gives itself, or
|
|
||||||
* `handled` (it took the message elsewhere); null sends it to the AI. */
|
|
||||||
export type PetLocalReply = { answer: string } | { handled: true } | null;
|
|
||||||
|
|
||||||
interface PetChatProps {
|
|
||||||
/** The pet's shown name, for the header and greeting. */
|
|
||||||
name: string;
|
|
||||||
/** Shown in place of the plain name (the rename button). */
|
|
||||||
title?: ReactNode;
|
|
||||||
/** Extra header buttons, before Open in workspace (the pat heart). */
|
|
||||||
headerActions?: ReactNode;
|
|
||||||
/** Under the header: what the pet knows (status, sessions that need you). */
|
|
||||||
info?: ReactNode;
|
|
||||||
/** Asked first while no chat runs. */
|
|
||||||
onLocal?: (text: string) => PetLocalReply;
|
|
||||||
onClose: () => void;
|
|
||||||
/** Opens the chat's pane in its workspace. */
|
|
||||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
|
||||||
/** The thinking mood while a reply is being written. */
|
|
||||||
onBusy?: (busy: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PetChat({
|
|
||||||
name,
|
|
||||||
title,
|
|
||||||
headerActions,
|
|
||||||
info,
|
|
||||||
onLocal,
|
|
||||||
onClose,
|
|
||||||
onOpenWorkspace,
|
|
||||||
onBusy,
|
|
||||||
}: PetChatProps) {
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
|
||||||
// Sent but not in the transcript yet.
|
|
||||||
const [pending, setPending] = useState<string | null>(null);
|
|
||||||
const [starting, setStarting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [draft, setDraft] = useState("");
|
|
||||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
|
||||||
// The pet's own answers, given before any chat ran.
|
|
||||||
const [local, setLocal] = useState<Line[]>([]);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const remember = (ref: PetChatRef | null) => {
|
|
||||||
writePetChat(ref);
|
|
||||||
setChat(ref);
|
|
||||||
setSessionId(null);
|
|
||||||
setMessages([]);
|
|
||||||
setMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chat) return;
|
|
||||||
let live = true;
|
|
||||||
const check = () =>
|
|
||||||
api.run
|
|
||||||
.get(chat.runId)
|
|
||||||
.then((h) => {
|
|
||||||
if (!live) return;
|
|
||||||
if (h.status !== "running") remember(null);
|
|
||||||
else if (h.sessionId) setSessionId(h.sessionId);
|
|
||||||
})
|
|
||||||
.catch(() => live && remember(null));
|
|
||||||
void check();
|
|
||||||
// Codex learns its session only after a while; Claude's is known at start.
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
if (!sessionId) void check();
|
|
||||||
}, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [chat?.runId, sessionId]);
|
|
||||||
|
|
||||||
// The conversation, polled while the window is open.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sessionId) return;
|
|
||||||
let live = true;
|
|
||||||
const load = () =>
|
|
||||||
api.sessions
|
|
||||||
.transcript(sessionId, { limit: 60 })
|
|
||||||
.then((r) => {
|
|
||||||
if (!live) return;
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.length === r.messages.length &&
|
|
||||||
JSON.stringify(prev[prev.length - 1]) ===
|
|
||||||
JSON.stringify(r.messages[r.messages.length - 1])
|
|
||||||
? prev
|
|
||||||
: r.messages
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => undefined); // not started yet: the next poll asks again
|
|
||||||
void load();
|
|
||||||
const timer = window.setInterval(load, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [sessionId]);
|
|
||||||
|
|
||||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chat) return;
|
|
||||||
const read = () =>
|
|
||||||
setMenu((prev) => {
|
|
||||||
const next = readRunMenu(chat.runId);
|
|
||||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
|
||||||
});
|
|
||||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [chat]);
|
|
||||||
|
|
||||||
const lines = [...local, ...chatLines(messages)];
|
|
||||||
// The pending line shows until the transcript has it.
|
|
||||||
useEffect(() => {
|
|
||||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
|
||||||
setPending(null);
|
|
||||||
}, [lines, pending]);
|
|
||||||
const busy = starting || !!pending || isBusy(messages);
|
|
||||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
|
||||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
|
||||||
|
|
||||||
// Keep the newest in sight.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = listRef.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
}, [lines.length, pending, busy, menu]);
|
|
||||||
|
|
||||||
const send = useCallback(
|
|
||||||
async (text: string) => {
|
|
||||||
const prompt = text.trim();
|
|
||||||
if (!prompt || starting) return;
|
|
||||||
setError(null);
|
|
||||||
if (!chat && onLocal) {
|
|
||||||
const reply = onLocal(prompt);
|
|
||||||
if (reply && "handled" in reply) return;
|
|
||||||
if (reply) {
|
|
||||||
const n = Date.now();
|
|
||||||
setLocal((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ key: `lu${n}`, role: "user", text: prompt },
|
|
||||||
{ key: `la${n}`, role: "assistant", text: reply.answer },
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setPending(prompt);
|
|
||||||
if (chat) {
|
|
||||||
// Typed into the attached pane; it may still be attaching.
|
|
||||||
for (let i = 0; i < SEND_TRIES; i++) {
|
|
||||||
if (submitToRun(chat.runId, prompt)) return;
|
|
||||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
|
||||||
}
|
|
||||||
setPending(null);
|
|
||||||
setDraft(prompt);
|
|
||||||
setError(t("sagi.chat.notSent"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tool = petChatTool() ?? "claude";
|
|
||||||
setStarting(true);
|
|
||||||
try {
|
|
||||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
|
||||||
const r = await startInFreeTabDetailed(lane.id, {
|
|
||||||
prompt,
|
|
||||||
...(tool !== lane.tool ? { tool } : {}),
|
|
||||||
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
|
|
||||||
});
|
|
||||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
|
||||||
} catch (err) {
|
|
||||||
setPending(null);
|
|
||||||
setDraft(prompt);
|
|
||||||
setError(
|
|
||||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setStarting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[chat, starting, t, onLocal]
|
|
||||||
);
|
|
||||||
|
|
||||||
// New chat: the old one's session is stopped, not left running unseen —
|
|
||||||
// asking first only while it is still writing a reply.
|
|
||||||
const newChat = async () => {
|
|
||||||
if (chat) {
|
|
||||||
if (
|
|
||||||
busy &&
|
|
||||||
!(await confirmDialog({
|
|
||||||
title: t("sagi.chat.endTitle"),
|
|
||||||
message: t("sagi.chat.endBody"),
|
|
||||||
confirmLabel: t("sagi.chat.endConfirm"),
|
|
||||||
destructive: true,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
try {
|
|
||||||
await api.run.kill(chat.runId, { archive: true });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remember(null);
|
|
||||||
setLocal([]);
|
|
||||||
setPending(null);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (e?: FormEvent) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
if (!draft.trim()) return;
|
|
||||||
const text = draft;
|
|
||||||
setDraft("");
|
|
||||||
void send(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
|
||||||
const empty = lines.length === 0 && !pending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
|
||||||
role="dialog"
|
|
||||||
aria-label={t("sagi.chat.label", { name })}
|
|
||||||
>
|
|
||||||
{/* header */}
|
|
||||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
|
||||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
|
||||||
🐾
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1 px-1">
|
|
||||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
|
||||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
|
||||||
<span
|
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
|
||||||
busy
|
|
||||||
? "animate-pulse bg-status-warning"
|
|
||||||
: chat
|
|
||||||
? "bg-status-success"
|
|
||||||
: "bg-fg-muted"
|
|
||||||
}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{headerActions}
|
|
||||||
{chat && (
|
|
||||||
<IconButton
|
|
||||||
label={t("sagi.chat.openWorkspace")}
|
|
||||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
|
||||||
>
|
|
||||||
<ExternalLink size={15} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
label={t("sagi.chat.new")}
|
|
||||||
onClick={() => void newChat()}
|
|
||||||
disabled={(!chat && !local.length) || starting}
|
|
||||||
>
|
|
||||||
<SquarePen size={15} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
|
||||||
<X size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{info}
|
|
||||||
|
|
||||||
{/* conversation */}
|
|
||||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
|
||||||
{empty && (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
|
||||||
<span className="text-3xl" aria-hidden>
|
|
||||||
🐾
|
|
||||||
</span>
|
|
||||||
<p className="text-sm font-medium text-fg-primary">
|
|
||||||
{t("sagi.chat.emptyTitle", { name })}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{lines.map((l) =>
|
|
||||||
l.role === "tools" ? (
|
|
||||||
<div key={l.key} className="flex justify-start">
|
|
||||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
|
||||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
|
||||||
<span className="truncate">{toolSummary(l.names)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : l.role === "user" ? (
|
|
||||||
<div key={l.key} className="flex justify-end">
|
|
||||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
|
||||||
{l.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={l.key} className="flex justify-start">
|
|
||||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
|
||||||
<MarkdownContent text={l.text} dense prose />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{pending && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
|
||||||
{pending}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{busy && !menu && (
|
|
||||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
|
||||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
|
||||||
<span className="pet-chat-dot" />
|
|
||||||
<span className="pet-chat-dot" />
|
|
||||||
<span className="pet-chat-dot" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{starting && (
|
|
||||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
|
||||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
|
||||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{chat && !sessionId && !starting && (
|
|
||||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* prompt, or the question the CLI asks */}
|
|
||||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
|
||||||
{menu && chat ? (
|
|
||||||
<div className="max-h-56 overflow-y-auto">
|
|
||||||
<PaneMenuCard
|
|
||||||
menu={menu}
|
|
||||||
onChoose={(i) => {
|
|
||||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
|
||||||
else setError(t("sagi.chat.notSent"));
|
|
||||||
}}
|
|
||||||
onAnswer={(text) => {
|
|
||||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
|
||||||
else setError(t("sagi.chat.notSent"));
|
|
||||||
}}
|
|
||||||
onStep={(dir) => {
|
|
||||||
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
if (interruptRun(chat.runId)) setMenu(null);
|
|
||||||
else setError(t("sagi.chat.notSent"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
|
||||||
<textarea
|
|
||||||
rows={1}
|
|
||||||
autoFocus
|
|
||||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
|
||||||
placeholder={t("sagi.chat.placeholder", { name })}
|
|
||||||
aria-label={t("sagi.chat.placeholder", { name })}
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
submit();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.nativeEvent.stopImmediatePropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!draft.trim() || starting}
|
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
|
||||||
aria-label={t("sagi.panel.send")}
|
|
||||||
>
|
|
||||||
<ArrowUp size={16} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
|
||||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
|
||||||
{chat && (
|
|
||||||
<div className="pet-chat-term" aria-hidden>
|
|
||||||
<TerminalView
|
|
||||||
runId={chat.runId}
|
|
||||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
|
||||||
autoFocus={false}
|
|
||||||
// The pane ended: the conversation stays on screen, the next
|
|
||||||
// message starts a new session.
|
|
||||||
onExit={() => {
|
|
||||||
writePetChat(null);
|
|
||||||
setChat(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconButton({
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @file PetPanel.tsx
|
||||||
|
* @description The pet's window — clicking the pet opens it: its name (which
|
||||||
|
* the user can rename), a pat button, and what the pet knows (`info`: live /
|
||||||
|
* waiting / errored, the sessions that need you, Mute, Clear alerts —
|
||||||
|
* `SagiInfo.tsx`). The pet no longer chats: it started a full AI session per
|
||||||
|
* question, a heavy process on a shared server, so the chat was removed
|
||||||
|
* (v2.8.68); this window is where the pet's own care comes next.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
export function PetPanel({
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
headerActions,
|
||||||
|
info,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
/** The name line (a rename button); plain text without it. */
|
||||||
|
title?: ReactNode;
|
||||||
|
/** Buttons before Close (the pat). */
|
||||||
|
headerActions?: ReactNode;
|
||||||
|
/** What the pet knows (`SagiInfo`). */
|
||||||
|
info: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pet-chat flex max-h-[calc(100vh-6rem)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t("sagi.panel.label", { name })}
|
||||||
|
>
|
||||||
|
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||||
|
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||||
|
🐾
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 px-1">
|
||||||
|
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||||
|
</div>
|
||||||
|
{headerActions}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t("sagi.panel.close", { name })}
|
||||||
|
title={t("sagi.panel.close", { name })}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Vertical only: an auto overflow-y makes x scrollable too, and the
|
||||||
|
status chips' row then showed a horizontal scrollbar. */}
|
||||||
|
<div className="min-w-0 overflow-y-auto overflow-x-hidden">{info}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,8 @@
|
|||||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
* 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.
|
|
||||||
* 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
|
||||||
@@ -15,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>
|
||||||
*/
|
*/
|
||||||
@@ -91,15 +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";
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -229,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);
|
||||||
@@ -340,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) => {
|
||||||
@@ -398,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,
|
||||||
@@ -412,11 +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>
|
||||||
<PetChat
|
<PetPanel
|
||||||
name={name}
|
name={name}
|
||||||
title={
|
title={
|
||||||
<PetNameButton
|
<PetNameButton
|
||||||
@@ -449,13 +402,7 @@ 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}
|
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file PetChat.test.ts
|
|
||||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { chatLines } from "../PetChat";
|
|
||||||
import type { TranscriptMessage } from "../../../lib/types";
|
|
||||||
|
|
||||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
|
||||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
|
||||||
|
|
||||||
describe("chatLines", () => {
|
|
||||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
|
||||||
const lines = chatLines([
|
|
||||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
|
||||||
msg({
|
|
||||||
type: "assistant",
|
|
||||||
content: [
|
|
||||||
{ type: "thinking", text: "hmm" },
|
|
||||||
{ type: "tool_use", name: "Read" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
|
||||||
msg({
|
|
||||||
type: "assistant",
|
|
||||||
content: [
|
|
||||||
{ type: "tool_use", name: "Read" },
|
|
||||||
{ type: "text", text: "hello!" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
|
||||||
"hi",
|
|
||||||
["Read", "Read"],
|
|
||||||
"hello!",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops Claude Code's own injected lines", () => {
|
|
||||||
const lines = chatLines([
|
|
||||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
|
||||||
]);
|
|
||||||
expect(lines).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sagi.test.tsx
|
* @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,26 +148,40 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
|
||||||
|
renderSagi();
|
||||||
|
act(() => {
|
||||||
|
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
|
||||||
|
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
|
||||||
|
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||||
|
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||||
|
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/theirs/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("respects the enabled preference", () => {
|
it("respects the enabled preference", () => {
|
||||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||||
renderSagi();
|
renderSagi();
|
||||||
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(() => {
|
||||||
@@ -214,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", () => {
|
||||||
@@ -243,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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -529,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,
|
||||||
@@ -547,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 ════════ */
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* burst of the same event into one count), the pet's one-shot animation
|
* burst of the same event into one count), the pet's one-shot animation
|
||||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||||
* for the UI shell. It also greets once per browser session and makes an
|
* for the UI shell. It also greets once per browser session and makes an
|
||||||
* idle pet fidget now and then.
|
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
|
||||||
|
* (`mine=1` seed, and frames marked `mine: false` skipped).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
}, GREET_DELAY_MS);
|
}, GREET_DELAY_MS);
|
||||||
};
|
};
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ status: "active", limit: 100 })
|
.list({ status: "active", limit: 100, mine: true })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||||
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
// Subscribe to the live stream and connection status.
|
// Subscribe to the live stream and connection status.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||||
|
// Each user's pet tells only of their own sessions: an admin/analyst
|
||||||
|
// gets every user's, marked `mine: false` when not theirs.
|
||||||
|
if (msg.mine === false) return;
|
||||||
const prev = stateRef.current;
|
const prev = stateRef.current;
|
||||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
* the next option whose label starts with it. Focus returns to the trigger on
|
* 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();
|
||||||
@@ -258,57 +292,100 @@ export function Select<T extends string>({
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
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 ${
|
||||||
id={`${id}-list`}
|
|
||||||
role="listbox"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={label}
|
|
||||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
|
||||||
onKeyDown={onListKey}
|
|
||||||
onBlur={(e) => {
|
|
||||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
|
||||||
}}
|
|
||||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
|
|
||||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{options.map((opt, idx) => {
|
{canSearch && (
|
||||||
const isSelected = opt.value === value;
|
<div className="flex shrink-0 items-center gap-1.5 border-b border-border px-2.5 py-1.5">
|
||||||
const isActive = idx === active;
|
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||||
return (
|
<input
|
||||||
<div
|
ref={searchRef}
|
||||||
key={opt.value || "__default__"}
|
role="combobox"
|
||||||
id={optionId(idx)}
|
aria-label={searchLabel ?? t("select.search")}
|
||||||
role="option"
|
aria-expanded
|
||||||
aria-selected={isSelected}
|
aria-controls={`${id}-list`}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
aria-autocomplete="list"
|
||||||
onClick={() => choose(opt)}
|
aria-activedescendant={shown[active] ? optionId(active) : undefined}
|
||||||
onMouseEnter={() => setActive(idx)}
|
placeholder={searchLabel ?? t("select.search")}
|
||||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
value={query}
|
||||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
onChange={(e) => {
|
||||||
} ${
|
setQuery(e.target.value);
|
||||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
setActive(0);
|
||||||
}`}
|
}}
|
||||||
>
|
onKeyDown={onListKey}
|
||||||
<div className="flex items-center gap-2">
|
onBlur={(e) => {
|
||||||
{opt.icon}
|
if (!containerRef.current?.contains(e.relatedTarget as Node | null))
|
||||||
<span
|
setOpen(false);
|
||||||
className={`text-[11px] flex-1 truncate ${
|
}}
|
||||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
id={`${id}-list`}
|
||||||
|
role="listbox"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={label}
|
||||||
|
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
|
||||||
|
onKeyDown={onListKey}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||||
|
}}
|
||||||
|
className="min-h-0 flex-1 overflow-auto py-1 outline-none"
|
||||||
|
>
|
||||||
|
{canSearch && shown.length === 0 && (
|
||||||
|
<div className="px-3 py-1.5 text-[11px] text-fg-muted">{t("select.noMatch")}</div>
|
||||||
|
)}
|
||||||
|
{shown.map((opt, idx) => {
|
||||||
|
const isSelected = opt.value === value;
|
||||||
|
const isActive = idx === active;
|
||||||
|
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
|
||||||
|
return (
|
||||||
|
<div key={opt.value || "__default__"} role="none">
|
||||||
|
{heading && (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
className="mt-1 border-t border-border px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-fg-muted"
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
id={optionId(idx)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => choose(opt)}
|
||||||
|
onMouseEnter={() => setActive(idx)}
|
||||||
|
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||||
|
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||||
|
} ${
|
||||||
|
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
{opt.icon}
|
||||||
{isSelected && (
|
<span
|
||||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
className={`text-[11px] flex-1 truncate ${
|
||||||
)}
|
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{opt.hint && (
|
||||||
|
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{opt.hint && (
|
);
|
||||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* which the sidebar owns and which also carries the update check), the version
|
* which the sidebar owns and which also carries the update check), the version
|
||||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
|
||||||
|
* OWN sessions even for an admin —, re-read on session
|
||||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
||||||
* user's OWN spend even for an admin — and, on the
|
* user's OWN spend even for an admin — and, on the
|
||||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
@@ -37,6 +38,12 @@ import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
|
|||||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||||
import { AiToolIcon } from "./AiToolIcon";
|
import { AiToolIcon } from "./AiToolIcon";
|
||||||
|
import {
|
||||||
|
HIDDEN_PROFILES_EVENT,
|
||||||
|
isHiddenProfilesKey,
|
||||||
|
readHiddenProfiles,
|
||||||
|
standIn,
|
||||||
|
} from "../lib/hiddenProfilesStorage";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import { useSpin } from "../hooks/useSpin";
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { sessionChangeMatcher } from "../lib/sessionChange";
|
import { sessionChangeMatcher } from "../lib/sessionChange";
|
||||||
@@ -275,10 +282,44 @@ function QuotaPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The profiles hidden in Settings → AI tools → Profiles, kept current. */
|
||||||
|
function useHiddenProfiles(): string[] {
|
||||||
|
const [hidden, setHidden] = useState<string[]>(readHiddenProfiles);
|
||||||
|
useEffect(() => {
|
||||||
|
const again = () => setHidden(readHiddenProfiles());
|
||||||
|
const onStorage = (e: StorageEvent) => isHiddenProfilesKey(e.key) && again();
|
||||||
|
window.addEventListener(HIDDEN_PROFILES_EVENT, again);
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(HIDDEN_PROFILES_EVENT, again);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude rows without the hidden profiles. When the live login is one of
|
||||||
|
* them, the first visible profile stands in as the live row — its own usage
|
||||||
|
* on the chip and the panel — so the panel never names the hidden account.
|
||||||
|
*/
|
||||||
|
export function withoutHidden(tools: ToolQuota[], hidden: string[]): ToolQuota[] {
|
||||||
|
if (!hidden.length) return tools;
|
||||||
|
const claude = tools.filter((q) => q.id === "claude");
|
||||||
|
const isLive = (q: ToolQuota) => q.active !== false;
|
||||||
|
const decoy = standIn(claude, isLive, hidden);
|
||||||
|
const keep = (q: ToolQuota) =>
|
||||||
|
q.id !== "claude" || !q.accountUuid || !hidden.includes(q.accountUuid);
|
||||||
|
const shown = tools.filter(keep);
|
||||||
|
if (!decoy) return shown;
|
||||||
|
// The stand-in leads the Claude rows, as the live login does.
|
||||||
|
return [{ ...decoy, active: true }, ...shown.filter((q) => q !== decoy)];
|
||||||
|
}
|
||||||
|
|
||||||
/** The right-end quota chips; nothing at all until a tool reports one. */
|
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||||
function QuotaChips() {
|
function QuotaChips() {
|
||||||
const { t } = useTranslation("nav");
|
const { t } = useTranslation("nav");
|
||||||
const [tools, setTools] = useState<ToolQuota[]>([]);
|
const [fetched, setTools] = useState<ToolQuota[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Leaving the chips closes the panel only after a beat, and entering the
|
// Leaving the chips closes the panel only after a beat, and entering the
|
||||||
// panel cancels that: the pointer crosses the gap to read it.
|
// panel cancels that: the pointer crosses the gap to read it.
|
||||||
@@ -302,6 +343,8 @@ function QuotaChips() {
|
|||||||
);
|
);
|
||||||
const [refreshing, refresh] = useSpin(() => load(true));
|
const [refreshing, refresh] = useSpin(() => load(true));
|
||||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||||
|
const hidden = useHiddenProfiles();
|
||||||
|
const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]);
|
||||||
if (!tools.length) return null;
|
if (!tools.length) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -388,11 +431,11 @@ export function StatusBar({
|
|||||||
|
|
||||||
const loadCounts = useCallback(() => {
|
const loadCounts = useCallback(() => {
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ status: "active", limit: 1 })
|
.list({ status: "active", limit: 1, mine: true })
|
||||||
.then((r) => setActive(r.total))
|
.then((r) => setActive(r.total))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ awaiting: true, limit: 1 })
|
.list({ awaiting: true, limit: 1, mine: true })
|
||||||
.then((r) => setNeedYou(r.total))
|
.then((r) => setNeedYou(r.total))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ import { UserAvatar } from "./users/UserAvatar";
|
|||||||
const AddLaneModal = lazy(() =>
|
const AddLaneModal = lazy(() =>
|
||||||
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
|
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
|
||||||
);
|
);
|
||||||
|
const WorkspaceShareModal = lazy(() =>
|
||||||
|
import("./lanes/WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
|
||||||
|
);
|
||||||
const DestructiveLaneModal = lazy(() =>
|
const DestructiveLaneModal = lazy(() =>
|
||||||
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
|
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
|
||||||
);
|
);
|
||||||
@@ -116,7 +119,9 @@ export function WorkspaceTree() {
|
|||||||
const selected = selectedFrom(location.pathname, location.search);
|
const selected = selectedFrom(location.pathname, location.search);
|
||||||
const { lanes, refresh: refreshLanes } = useLanes();
|
const { lanes, refresh: refreshLanes } = useLanes();
|
||||||
const { state: auth } = useAuth();
|
const { state: auth } = useAuth();
|
||||||
const { seesAll, canEditLane } = usePermissions();
|
const { seesAll, canEditLane, ownsLane } = usePermissions();
|
||||||
|
// Sharing a workspace with other users (server mode): the open dialog's lane.
|
||||||
|
const [sharing, setSharing] = useState<Lane | null>(null);
|
||||||
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||||
// Server mode staff see every user's workspaces, so the list says whose.
|
// Server mode staff see every user's workspaces, so the list says whose.
|
||||||
const grouped = auth.mode === "server" && seesAll;
|
const grouped = auth.mode === "server" && seesAll;
|
||||||
@@ -375,6 +380,7 @@ export function WorkspaceTree() {
|
|||||||
|
|
||||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||||
if (action === "rename") setRenamingId(lane.id);
|
if (action === "rename") setRenamingId(lane.id);
|
||||||
|
else if (action === "share") setSharing(lane);
|
||||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||||
};
|
};
|
||||||
@@ -559,7 +565,8 @@ export function WorkspaceTree() {
|
|||||||
onNewSession={() => select(lane.id)}
|
onNewSession={() => select(lane.id)}
|
||||||
onReorder={nudge}
|
onReorder={nudge}
|
||||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||||
onRenameStart={() => setRenamingId(lane.id)}
|
// A workspace shared with this user keeps its owner's name.
|
||||||
|
onRenameStart={() => ownsLane(lane) && setRenamingId(lane.id)}
|
||||||
onRenameEnd={(title) => void rename(lane, title)}
|
onRenameEnd={(title) => void rename(lane, title)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -688,6 +695,7 @@ export function WorkspaceTree() {
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
{sharing && <WorkspaceShareModal lane={sharing} onClose={() => setSharing(null)} />}
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<DestructiveLaneModal
|
<DestructiveLaneModal
|
||||||
lane={destructive.lane}
|
lane={destructive.lane}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ vi.mock("../../lib/api", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { StatusBar } from "../StatusBar";
|
import { StatusBar, withoutHidden } from "../StatusBar";
|
||||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||||
|
|
||||||
const at = (url: string, ui: React.JSX.Element) =>
|
const at = (url: string, ui: React.JSX.Element) =>
|
||||||
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
|
|||||||
);
|
);
|
||||||
await screen.findByText("1 need you");
|
await screen.findByText("1 need you");
|
||||||
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
||||||
|
// The user's OWN sessions, even for an admin who sees every user's rows.
|
||||||
|
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
|
||||||
|
expect(p).toMatchObject({ mine: true });
|
||||||
await user.click(conn);
|
await user.click(conn);
|
||||||
expect(onConnectionClick).toHaveBeenCalled();
|
expect(onConnectionClick).toHaveBeenCalled();
|
||||||
// Polite live region: the connection and who is waiting, nothing that ticks.
|
// Polite live region: the connection and who is waiting, nothing that ticks.
|
||||||
@@ -304,3 +307,35 @@ describe("StatusBar", () => {
|
|||||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("withoutHidden", () => {
|
||||||
|
const row = (accountUuid: string, active: boolean, pct: number) => ({
|
||||||
|
id: "claude" as const,
|
||||||
|
active,
|
||||||
|
accountUuid,
|
||||||
|
account: `${accountUuid}@x.y`,
|
||||||
|
windows: [{ label: "5h", usedPercent: pct, resetsAt: null }],
|
||||||
|
});
|
||||||
|
const codex = { id: "codex" as const, active: true, windows: [] };
|
||||||
|
|
||||||
|
it("leaves the list alone when nothing is hidden", () => {
|
||||||
|
const list = [row("u1", true, 82), row("u2", false, 19), codex];
|
||||||
|
expect(withoutHidden(list, [])).toBe(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a hidden saved profile", () => {
|
||||||
|
const out = withoutHidden([row("u1", true, 82), row("u2", false, 19), codex], ["u2"]);
|
||||||
|
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u1", "codex"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the first visible profile, with its own usage, in place of a hidden live login", () => {
|
||||||
|
const out = withoutHidden(
|
||||||
|
[row("u1", true, 82), row("u2", false, 19), row("u3", false, 0), codex],
|
||||||
|
["u1"]
|
||||||
|
);
|
||||||
|
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u2", "u3", "codex"]);
|
||||||
|
expect(out[0]?.active).toBe(true);
|
||||||
|
expect(out[0]?.windows[0]?.usedPercent).toBe(19);
|
||||||
|
expect(out[1]?.active).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||||
* busiest processes with who each counts for (history, CPU threads and
|
* busiest processes with who each counts for (history, CPU threads and
|
||||||
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||||
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
|
||||||
|
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
|
||||||
|
* workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
|
||||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||||
* Administration users table shares for each home's share of the homes disk.
|
* Administration users table shares for each home's share of the homes disk.
|
||||||
* Read-only.
|
* Read-only.
|
||||||
@@ -38,14 +40,17 @@ import {
|
|||||||
type HostHistoryRange,
|
type HostHistoryRange,
|
||||||
type HostStats,
|
type HostStats,
|
||||||
type HostUsageGroup,
|
type HostUsageGroup,
|
||||||
|
type ServerUserUsage,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import { Tip } from "../Tip";
|
import { Tip } from "../Tip";
|
||||||
import { MiniChart } from "../admin/MiniChart";
|
import { MiniChart } from "../admin/MiniChart";
|
||||||
import { UserAvatar } from "../users/UserAvatar";
|
import { UserAvatar } from "../users/UserAvatar";
|
||||||
|
|
||||||
const POLL_MS = 5000;
|
const POLL_MS = 5000;
|
||||||
const HISTORY_POLL_MS = 30000;
|
const HISTORY_POLL_MS = 30000;
|
||||||
|
const USERS_POLL_MS = 60000;
|
||||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||||
|
|
||||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||||
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
|||||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||||
// The user tile whose busiest processes are shown.
|
// The user tile whose busiest processes are shown.
|
||||||
const [open, setOpen] = useState<string | null>(null);
|
const [open, setOpen] = useState<string | null>(null);
|
||||||
|
// Each account's role and spend for the tiles' hover card. `GET /api/users`
|
||||||
|
// is admin/analyst only, so a developer gets no card.
|
||||||
|
const { seesAll, local } = usePermissions();
|
||||||
|
const { t: tu } = useTranslation("users");
|
||||||
|
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
|
||||||
|
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seesAll || local || !hasPeople) return;
|
||||||
|
const load = () =>
|
||||||
|
api.users
|
||||||
|
.list()
|
||||||
|
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
|
||||||
|
.catch(() => {});
|
||||||
|
void load();
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") void load();
|
||||||
|
}, USERS_POLL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [seesAll, local, hasPeople]);
|
||||||
|
|
||||||
const loadHistory = useCallback(
|
const loadHistory = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -365,6 +389,29 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
|||||||
if (key === "system") return t("host.systemGroup");
|
if (key === "system") return t("host.systemGroup");
|
||||||
return key;
|
return key;
|
||||||
};
|
};
|
||||||
|
const userTip = (g: HostUsageGroup) => {
|
||||||
|
const u = g.userId != null ? accounts.get(g.userId) : undefined;
|
||||||
|
// No account row (a developer, or not read yet): no card at all.
|
||||||
|
if (!u) return "";
|
||||||
|
const lines = [groupName(g)];
|
||||||
|
{
|
||||||
|
lines.push(
|
||||||
|
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
|
||||||
|
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
|
||||||
|
);
|
||||||
|
if (u.today)
|
||||||
|
lines.push(
|
||||||
|
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
|
||||||
|
);
|
||||||
|
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
|
||||||
|
lines.push(
|
||||||
|
t("host.userTipLastLogin", {
|
||||||
|
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
};
|
||||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||||
const timeLabel = (iso: string) =>
|
const timeLabel = (iso: string) =>
|
||||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
@@ -676,26 +723,30 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
|||||||
const isOpen = open === g.key;
|
const isOpen = open === g.key;
|
||||||
return (
|
return (
|
||||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||||
<button
|
{/* The hover card sits on the name row only: over the whole
|
||||||
type="button"
|
tile it stacked on the CPU bar's own help tip. */}
|
||||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
<Tip raw={userTip(g)} block>
|
||||||
disabled={!g.top.length}
|
<button
|
||||||
aria-expanded={isOpen}
|
type="button"
|
||||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||||
>
|
disabled={!g.top.length}
|
||||||
<UserAvatar
|
aria-expanded={isOpen}
|
||||||
name={name}
|
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||||
userId={g.userId}
|
>
|
||||||
avatarAt={g.avatarAt}
|
<UserAvatar
|
||||||
className="h-6 w-6 text-[10px]"
|
name={name}
|
||||||
/>
|
userId={g.userId}
|
||||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
avatarAt={g.avatarAt}
|
||||||
{name}
|
className="h-6 w-6 text-[10px]"
|
||||||
</span>
|
/>
|
||||||
<span className="text-[10px] font-mono text-fg-muted">
|
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||||
{t("host.processCount", { n: g.processes })}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
<span className="text-[10px] font-mono text-fg-muted">
|
||||||
|
{t("host.processCount", { n: g.processes })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tip>
|
||||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* rate and each network interface, the CPU / memory history with its range
|
* rate and each network interface, the CPU / memory history with its range
|
||||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||||
* counts for, the processes of no user in a short list of their own, and
|
* counts for, the processes of no user in a short list of their own, and
|
||||||
* each user's live CPU / RAM as a tile (their busiest processes on click);
|
* each user's live CPU / RAM as a tile (their busiest processes on click; on
|
||||||
|
* hover, for staff, their role and $ spent today);
|
||||||
* off Linux the cards it can never fill are left out.
|
* off Linux the cards it can never fill are left out.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -69,8 +70,10 @@ const stats: HostStats = {
|
|||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
host: { stats: vi.fn(), history: vi.fn() },
|
host: { stats: vi.fn(), history: vi.fn() },
|
||||||
me: { avatarUrl: () => null },
|
me: { avatarUrl: () => null },
|
||||||
users: { avatarUrl: () => null },
|
users: { avatarUrl: () => null, list: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
|
||||||
|
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
return { ...actual, api };
|
return { ...actual, api };
|
||||||
@@ -105,6 +108,7 @@ const full: HostStats = {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
|
||||||
api.host.stats.mockResolvedValue(full);
|
api.host.stats.mockResolvedValue(full);
|
||||||
api.host.history.mockResolvedValue({
|
api.host.history.mockResolvedValue({
|
||||||
range: "1h",
|
range: "1h",
|
||||||
@@ -182,6 +186,50 @@ describe("HostHealth", () => {
|
|||||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names the role and today's spend when a user tile is hovered", async () => {
|
||||||
|
Object.assign(perms, { local: false });
|
||||||
|
api.users.list.mockResolvedValue({
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: "mia",
|
||||||
|
role: "developer",
|
||||||
|
disabled: false,
|
||||||
|
workspaces: 2,
|
||||||
|
sessions: 5,
|
||||||
|
today: { cost: 1.5, tokens: 12000 },
|
||||||
|
last7d: { cost: 0, tokens: 0 },
|
||||||
|
last30d: { cost: 0, tokens: 0 },
|
||||||
|
lastLoginAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<Tab />);
|
||||||
|
const tile = await screen.findByTestId("server-user");
|
||||||
|
// The card exists only once the users list has arrived.
|
||||||
|
const nameRow = () => within(tile).getByRole("button").parentElement!;
|
||||||
|
const tip = await vi.waitFor(() => {
|
||||||
|
fireEvent.mouseEnter(nameRow());
|
||||||
|
return screen.getByText(/Role: Developer/);
|
||||||
|
});
|
||||||
|
expect(tip.textContent).not.toContain("Login");
|
||||||
|
expect(tip.textContent).toMatch(/Today: \$1\.5/);
|
||||||
|
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
|
||||||
|
// Only the name row carries it: the CPU bar keeps its own help tip alone.
|
||||||
|
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
|
||||||
|
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
|
||||||
|
expect(screen.queryByText(/Role: Developer/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a developer no hover card, without reading the users list", async () => {
|
||||||
|
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
|
||||||
|
render(<Tab />);
|
||||||
|
const tile = await screen.findByTestId("server-user");
|
||||||
|
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
|
||||||
|
expect(screen.queryByText(/Today:/)).toBeNull();
|
||||||
|
expect(api.users.list).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||||
api.host.stats.mockResolvedValue({
|
api.host.stats.mockResolvedValue({
|
||||||
...stats,
|
...stats,
|
||||||
|
|||||||
@@ -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. */}
|
||||||
value={picked}
|
<div className="min-w-[8rem] flex-1 font-mono">
|
||||||
disabled={!!busy}
|
<Select
|
||||||
onChange={(e) => setPicked(e.target.value)}
|
label={t("gitBar.switchLabel")}
|
||||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
searchLabel={t("gitBar.branchSearch")}
|
||||||
>
|
searchable
|
||||||
<option value="">{t("gitBar.switchLabel")}</option>
|
value={picked}
|
||||||
{(branches || []).map((b) => {
|
disabled={!!busy}
|
||||||
const held = heldBy.get(b);
|
onChange={setPicked}
|
||||||
return (
|
options={[
|
||||||
<option key={b} value={b}>
|
{ value: "", label: t("gitBar.switchLabel") },
|
||||||
{b}
|
...(branches || []).map((b) => {
|
||||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
const held = heldBy.get(b);
|
||||||
</option>
|
const note =
|
||||||
);
|
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
|
||||||
})}
|
return { value: b, label: `${b}${note}` };
|
||||||
{remoteBranches.length > 0 && (
|
}),
|
||||||
<optgroup label={t("gitBar.remoteBranches")}>
|
...remoteBranches.map((b) => ({
|
||||||
{remoteBranches.map((b) => (
|
value: b,
|
||||||
<option key={`remote:${b}`} value={b}>
|
label: b,
|
||||||
{b}
|
group: t("gitBar.remoteBranches"),
|
||||||
</option>
|
})),
|
||||||
))}
|
]}
|
||||||
</optgroup>
|
/>
|
||||||
)}
|
</div>
|
||||||
</select>
|
|
||||||
<Btn
|
<Btn
|
||||||
label={t("gitBar.switchApply")}
|
label={t("gitBar.switchApply")}
|
||||||
busy={busy === "switch"}
|
busy={busy === "switch"}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function SessionMenu({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onUnarchive,
|
onUnarchive,
|
||||||
onPin,
|
onPin,
|
||||||
|
onShare,
|
||||||
}: MenuPoint & {
|
}: MenuPoint & {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
@@ -42,6 +43,8 @@ export function SessionMenu({
|
|||||||
onUnarchive?: () => void;
|
onUnarchive?: () => void;
|
||||||
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||||
onPin?: () => void;
|
onPin?: () => void;
|
||||||
|
/** Share this session on its own (server mode, the workspace's owner). */
|
||||||
|
onShare?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -81,6 +84,11 @@ export function SessionMenu({
|
|||||||
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{onShare && session && (
|
||||||
|
<MenuItem testId="session-menu-share" onSelect={pick(onShare)}>
|
||||||
|
{t("sessionMenu.share")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{session && (
|
{session && (
|
||||||
<>
|
<>
|
||||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||||
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
* workspace home's right column (sticky: it follows the home's scroll) and,
|
||||||
|
* `compact`, folded at the bottom of the
|
||||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||||
@@ -157,7 +158,13 @@ function ContextBody({
|
|||||||
<aside
|
<aside
|
||||||
data-testid="workspace-context"
|
data-testid="workspace-context"
|
||||||
aria-label={t("home.contextPanel")}
|
aria-label={t("home.contextPanel")}
|
||||||
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
// On the home it follows the scroll (sticky in the home's scroller),
|
||||||
|
// scrolling itself — without a bar — when taller than the window.
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? ""
|
||||||
|
: "card no-scrollbar sticky top-2 max-h-[calc(100vh-7rem)] w-80 shrink-0 self-start overflow-y-auto p-0"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{view && !view.supported && (
|
{view && !view.supported && (
|
||||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import { AiToolIcon } from "../AiToolIcon";
|
|||||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||||
|
import { WorkspaceShareModal } from "./WorkspaceShareModal";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import { workspaceName } from "./WorkspaceRow";
|
import { workspaceName } from "./WorkspaceRow";
|
||||||
import { SessionMenu } from "./SessionMenu";
|
import { SessionMenu } from "./SessionMenu";
|
||||||
import { menuPoint, useContextMenu } from "../ContextMenu";
|
import { menuPoint, useContextMenu } from "../ContextMenu";
|
||||||
@@ -118,6 +120,7 @@ function HomeBody({
|
|||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
|
const { ownsLane } = usePermissions();
|
||||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||||
const [tool, setTool] = useState(lane.tool);
|
const [tool, setTool] = useState(lane.tool);
|
||||||
const entry = aiTools.byId(tool);
|
const entry = aiTools.byId(tool);
|
||||||
@@ -148,7 +151,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
|
||||||
? [
|
? [
|
||||||
@@ -195,6 +198,7 @@ function HomeBody({
|
|||||||
const [nameDraft, setNameDraft] = useState("");
|
const [nameDraft, setNameDraft] = useState("");
|
||||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [title, setTitle] = useState<string | null>(null);
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
const recentCtx = useContextMenu<Session>();
|
const recentCtx = useContextMenu<Session>();
|
||||||
|
|
||||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||||
@@ -270,10 +274,17 @@ function HomeBody({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const text = promptWithImages(prompt, images, claude);
|
const text = promptWithImages(prompt, images, claude);
|
||||||
|
// A workspace shared with this user: a session they start runs on their
|
||||||
|
// own account and is billed to them (the server answers ENOCOLLABACCOUNT
|
||||||
|
// when they have none).
|
||||||
|
const collab = !ownsLane(lane);
|
||||||
|
// Only a session of it is shared with them (phase 3): no new session here —
|
||||||
|
// they open that session and send it messages (the server answers ESESSIONSHARE).
|
||||||
|
const sessionOnly = lane.access === "session";
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
// Enter reaches here past the disabled button: the same guards again.
|
// Enter reaches here past the disabled button: the same guards again.
|
||||||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||||
if (startingRef.current || uploading || !text) return;
|
if (sessionOnly || startingRef.current || uploading || !text) return;
|
||||||
startingRef.current = true;
|
startingRef.current = true;
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -529,7 +540,7 @@ function HomeBody({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="workspace-home-start"
|
data-testid="workspace-home-start"
|
||||||
onClick={() => void start()}
|
onClick={() => void start()}
|
||||||
disabled={starting || uploading || !text}
|
disabled={sessionOnly || starting || uploading || !text}
|
||||||
aria-label={t("home.start")}
|
aria-label={t("home.start")}
|
||||||
title={t("home.start")}
|
title={t("home.start")}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
@@ -539,6 +550,11 @@ function HomeBody({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{collab && (
|
||||||
|
<p data-testid="workspace-home-collab" className="mt-2 text-xs text-fg-muted">
|
||||||
|
{t(sessionOnly ? "shared.sessionOnly" : "shared.ownAccount")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{/* The session's quieter choices, under the box like Claude Desktop's. */}
|
{/* The session's quieter choices, under the box like Claude Desktop's. */}
|
||||||
<div
|
<div
|
||||||
data-testid="workspace-home-options"
|
data-testid="workspace-home-options"
|
||||||
@@ -787,9 +803,16 @@ function HomeBody({
|
|||||||
x={menuAt.x}
|
x={menuAt.x}
|
||||||
y={menuAt.y}
|
y={menuAt.y}
|
||||||
onClose={() => setMenuAt(null)}
|
onClose={() => setMenuAt(null)}
|
||||||
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
|
onAction={(a) =>
|
||||||
|
a === "rename"
|
||||||
|
? setTitle(lane.title || "")
|
||||||
|
: a === "share"
|
||||||
|
? setSharing(true)
|
||||||
|
: onMenuAction(a)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sharing && <WorkspaceShareModal lane={lane} onClose={() => setSharing(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
||||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||||
* existing call: Rename (the row's inline field), New workspace here (the
|
* existing call: Rename (the row's inline field), New workspace here (the
|
||||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
* Add modal prefilled), Copy path, Share (server mode: `WorkspaceShareModal`),
|
||||||
|
* and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||||
* managed lane — plus Move to group (the sidebar's own groups,
|
* managed lane — plus Move to group (the sidebar's own groups,
|
||||||
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
|
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
|
||||||
* server's guards are the real ones; the menu only hides
|
* server's guards are the real ones; the menu only hides
|
||||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
* what the lane's kind or tool makes meaningless, and the owner-only items
|
||||||
|
* (Rename, Share, Reset, Remove) on a workspace shared with this user. The shell (position,
|
||||||
* Escape / outside-click close) is `ContextMenu`.
|
* Escape / outside-click close) is `ContextMenu`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -17,8 +19,9 @@ import { Check } from "lucide-react";
|
|||||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||||
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
|
||||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
export type WorkspaceMenuAction = "rename" | "new-here" | "share" | "reset" | "remove";
|
||||||
|
|
||||||
export function WorkspaceRowMenu({
|
export function WorkspaceRowMenu({
|
||||||
lane,
|
lane,
|
||||||
@@ -43,9 +46,11 @@ export function WorkspaceRowMenu({
|
|||||||
onGroup?: (target: string | null | "new") => void;
|
onGroup?: (target: string | null | "new") => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
|
const { local, ownsLane } = usePermissions();
|
||||||
|
const owner = ownsLane(lane);
|
||||||
return (
|
return (
|
||||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
||||||
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
|
{owner && <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>}
|
||||||
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -55,6 +60,11 @@ export function WorkspaceRowMenu({
|
|||||||
>
|
>
|
||||||
{t("menu.copyPath")}
|
{t("menu.copyPath")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{owner && !local && (
|
||||||
|
<MenuItem testId="workspace-menu-share" onSelect={() => onAction("share")}>
|
||||||
|
{t("menu.share")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{onGroup && (
|
{onGroup && (
|
||||||
<>
|
<>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
@@ -84,15 +94,19 @@ export function WorkspaceRowMenu({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<MenuSeparator />
|
{owner && (
|
||||||
{lane.kind === "managed" && (
|
<>
|
||||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
<MenuSeparator />
|
||||||
{t("menu.reset")}
|
{lane.kind === "managed" && (
|
||||||
</MenuItem>
|
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||||
|
{t("menu.reset")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||||
|
{t("menu.remove")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
|
||||||
{t("menu.remove")}
|
|
||||||
</MenuItem>
|
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
|
||||||
import { endRuns } from "../../lib/stopSession";
|
import { endRuns } from "../../lib/stopSession";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||||
@@ -46,6 +46,11 @@ import { useAiTools } from "../../hooks/useAiTools";
|
|||||||
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||||
import { useContextMenu } from "../ContextMenu";
|
import { useContextMenu } from "../ContextMenu";
|
||||||
import { SessionMenu } from "./SessionMenu";
|
import { SessionMenu } from "./SessionMenu";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
|
||||||
|
const WorkspaceShareModal = lazy(() =>
|
||||||
|
import("./WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
|
||||||
|
);
|
||||||
|
|
||||||
export const SESSION_LIMIT = 6;
|
export const SESSION_LIMIT = 6;
|
||||||
|
|
||||||
@@ -256,6 +261,9 @@ export function WorkspaceSessionList({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const ctx = useContextMenu<RunHandle>();
|
const ctx = useContextMenu<RunHandle>();
|
||||||
const sessionCtx = useContextMenu<Session>();
|
const sessionCtx = useContextMenu<Session>();
|
||||||
|
// Share one session (server mode, the workspace's owner or an admin).
|
||||||
|
const { local, ownsLane } = usePermissions();
|
||||||
|
const [sharing, setSharing] = useState<Session | null>(null);
|
||||||
// Rows shown; See all raises it to the whole list.
|
// Rows shown; See all raises it to the whole list.
|
||||||
const [limit, setLimit] = useState(SESSION_LIMIT);
|
const [limit, setLimit] = useState(SESSION_LIMIT);
|
||||||
|
|
||||||
@@ -637,8 +645,18 @@ export function WorkspaceSessionList({
|
|||||||
onPin={() =>
|
onPin={() =>
|
||||||
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
||||||
}
|
}
|
||||||
|
onShare={!local && ownsLane(lane) ? () => setSharing(sessionCtx.menu!.item) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sharing && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<WorkspaceShareModal
|
||||||
|
lane={lane}
|
||||||
|
session={{ id: sharing.id, name: sharing.name }}
|
||||||
|
onClose={() => setSharing(null)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* @file WorkspaceShareModal — share a workspace, or one of its sessions
|
||||||
|
* (`session`), with other users (server mode, docs/plans/workspace-sharing/
|
||||||
|
* phases 1 and 3). Ticks people from `GET /api/artifacts/people` (names only)
|
||||||
|
* and saves the whole list through `PUT /api/lanes/:id/shares` or
|
||||||
|
* `PUT /api/lanes/:id/sessions/:sid/shares`. It says plainly what sharing means on this
|
||||||
|
* server: the people it is shared with work as the owner's account and can
|
||||||
|
* read everything in the owner's home. Only the owner or an admin opens it —
|
||||||
|
* the server refuses anyone else (`ENOTOWNER`).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ShieldAlert, X } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
|
|
||||||
|
type Person = { id: number; username: string; name: string; disabled?: boolean };
|
||||||
|
|
||||||
|
export function WorkspaceShareModal({
|
||||||
|
lane,
|
||||||
|
session,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
lane: Lane;
|
||||||
|
/** Share this one session instead of the whole workspace. */
|
||||||
|
session?: { id: string; name?: string | null };
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(true, panelRef, onClose);
|
||||||
|
const [people, setPeople] = useState<Person[] | null>(null);
|
||||||
|
const [initial, setInitial] = useState<Set<number>>(new Set());
|
||||||
|
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const sid = session?.id;
|
||||||
|
Promise.all([
|
||||||
|
api.artifacts.people(),
|
||||||
|
sid ? api.lanes.sessionShares(lane.id, sid) : api.lanes.shares(lane.id),
|
||||||
|
])
|
||||||
|
.then(([p, s]) => {
|
||||||
|
if (!alive) return;
|
||||||
|
// Never the owner: a workspace is not shared with them. Someone it is
|
||||||
|
// already shared with but who was disabled since is not in `people`:
|
||||||
|
// listed anyway, so they can be taken off.
|
||||||
|
const known = new Set(p.people.map((x) => x.id));
|
||||||
|
setPeople([
|
||||||
|
...p.people.filter((x) => x.id !== lane.owner_id),
|
||||||
|
...s.shares.filter((x) => !known.has(x.id)).map((x) => ({ ...x, disabled: true })),
|
||||||
|
]);
|
||||||
|
const ids = new Set(s.shares.map((x) => x.id));
|
||||||
|
setInitial(ids);
|
||||||
|
setPicked(new Set(ids));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!alive) return;
|
||||||
|
toast.error(err);
|
||||||
|
setPeople([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [lane.id, lane.owner_id, session?.id]);
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return (people ?? []).filter(
|
||||||
|
(p) => !q || p.name.toLowerCase().includes(q) || p.username.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [people, query]);
|
||||||
|
const added = [...picked].filter((id) => !initial.has(id));
|
||||||
|
const changed = added.length > 0 || [...initial].some((id) => !picked.has(id));
|
||||||
|
const name = session ? session.name || session.id : lane.title || lane.cwd;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (added.length > 0) {
|
||||||
|
const names = (people ?? []).filter((p) => added.includes(p.id)).map((p) => p.name);
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("share.confirmTitle", { name }),
|
||||||
|
message: t("share.confirmBody", { people: names.join(", ") }),
|
||||||
|
confirmLabel: t("share.confirm"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = session
|
||||||
|
? await api.lanes.setSessionShares(lane.id, session.id, [...picked])
|
||||||
|
: await api.lanes.setShares(lane.id, [...picked]);
|
||||||
|
toast.success(
|
||||||
|
r.shares.length ? t("share.saved", { count: r.shares.length }) : t("share.savedNone")
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (id: number, on: boolean) =>
|
||||||
|
setPicked((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="workspace-share-title"
|
||||||
|
data-testid="workspace-share-modal"
|
||||||
|
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 id="workspace-share-title" className="text-base font-semibold text-fg-primary">
|
||||||
|
{t(session ? "share.sessionTitle" : "share.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-fg-muted" title={lane.cwd}>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t("share.close")}
|
||||||
|
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||||
|
<p className="text-xs text-fg-secondary">
|
||||||
|
{t(session ? "share.sessionWhat" : "share.what")}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
data-testid="workspace-share-warning"
|
||||||
|
className="flex gap-2 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning"
|
||||||
|
>
|
||||||
|
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{t("share.warning")}</span>
|
||||||
|
</div>
|
||||||
|
{people === null ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("share.loading")}</p>
|
||||||
|
) : people.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("share.nobody")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{people.length > 8 && (
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("share.search")}
|
||||||
|
aria-label={t("share.search")}
|
||||||
|
className="input w-full text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ul className="space-y-1" data-testid="workspace-share-people">
|
||||||
|
{shown.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Checkbox
|
||||||
|
checked={picked.has(p.id)}
|
||||||
|
onChange={(v) => toggle(p.id, v)}
|
||||||
|
disabled={busy}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{p.name}
|
||||||
|
{p.name !== p.username && (
|
||||||
|
<span className="ml-1.5 text-fg-muted">@{p.username}</span>
|
||||||
|
)}
|
||||||
|
{p.disabled && (
|
||||||
|
<span className="ml-1.5 text-fg-muted">{t("share.disabled")}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
|
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||||
|
{t("share.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
data-testid="workspace-share-save"
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={busy || !changed || people === null}
|
||||||
|
>
|
||||||
|
{busy ? t("share.saving") : t("share.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -171,21 +171,23 @@ beforeEach(() => {
|
|||||||
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
vi.mocked(h.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)
|
||||||
available: true,
|
.mockReset()
|
||||||
op,
|
.mockImplementation(async (_t, op, shas) => ({
|
||||||
commits: shas.map((sha) => ({
|
available: true,
|
||||||
sha,
|
op,
|
||||||
short: sha.slice(0, 7),
|
commits: shas.map((sha) => ({
|
||||||
subject: "feat: the commit",
|
sha,
|
||||||
author: "t",
|
short: sha.slice(0, 7),
|
||||||
date: "2026-09-22T01:00:00Z",
|
subject: "feat: the commit",
|
||||||
})),
|
author: "t",
|
||||||
branch: "feat/thing",
|
date: "2026-09-22T01:00:00Z",
|
||||||
clean: true,
|
})),
|
||||||
repoRoot: true,
|
branch: "feat/thing",
|
||||||
busy: false,
|
clean: true,
|
||||||
}));
|
repoRoot: true,
|
||||||
|
busy: false,
|
||||||
|
}));
|
||||||
vi.mocked(h.fileHistory)
|
vi.mocked(h.fileHistory)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({
|
.mockResolvedValue({
|
||||||
@@ -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,199 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for workspace sharing in the client: WorkspaceShareModal starts
|
||||||
|
* on the current list, never offers the owner, warns what sharing means, asks
|
||||||
|
* once before adding someone and saves the WHOLE list; WorkspaceRowMenu offers
|
||||||
|
* Share (server mode) and the other owner-only items only to the workspace's
|
||||||
|
* owner or an admin; given a session, it lists and saves THAT session's
|
||||||
|
* shares instead.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, act, fireEvent } from "@testing-library/react";
|
||||||
|
import "../../../i18n";
|
||||||
|
import i18n from "../../../i18n";
|
||||||
|
import { settleConfirm } from "../../../lib/confirm";
|
||||||
|
import { WorkspaceShareModal } from "../WorkspaceShareModal";
|
||||||
|
import { WorkspaceRowMenu } from "../WorkspaceRowMenu";
|
||||||
|
import type { Lane } from "../../../lib/types";
|
||||||
|
|
||||||
|
const perms = vi.hoisted(() => ({ local: false, me: 1, admin: false }));
|
||||||
|
vi.mock("../../AuthGate", () => ({
|
||||||
|
usePermissions: () => ({
|
||||||
|
local: perms.local,
|
||||||
|
isAdmin: perms.admin,
|
||||||
|
ownsLane: (l?: { owner_id?: number | null } | null) =>
|
||||||
|
perms.local || perms.admin || l?.owner_id === perms.me,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const m = vi.hoisted(() => ({
|
||||||
|
people: vi.fn(),
|
||||||
|
shares: vi.fn(),
|
||||||
|
setShares: vi.fn(),
|
||||||
|
sessionShares: vi.fn(),
|
||||||
|
setSessionShares: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
artifacts: { people: m.people },
|
||||||
|
lanes: {
|
||||||
|
shares: m.shares,
|
||||||
|
setShares: m.setShares,
|
||||||
|
sessionShares: m.sessionShares,
|
||||||
|
setSessionShares: m.setSessionShares,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lane = {
|
||||||
|
id: 7,
|
||||||
|
title: "Shop",
|
||||||
|
cwd: "/home/alice/shop",
|
||||||
|
owner_id: 1,
|
||||||
|
kind: "adopted",
|
||||||
|
} as Lane;
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
perms.local = false;
|
||||||
|
perms.me = 1;
|
||||||
|
perms.admin = false;
|
||||||
|
m.people.mockResolvedValue({
|
||||||
|
people: [
|
||||||
|
{ id: 1, username: "alice", name: "Alice" },
|
||||||
|
{ id: 2, username: "bob", name: "Bob" },
|
||||||
|
{ id: 3, username: "carol", name: "Carol" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
m.shares.mockResolvedValue({
|
||||||
|
shares: [{ id: 2, username: "bob", name: "Bob", sharedAt: "2026-11-06T00:00:00Z" }],
|
||||||
|
canManage: true,
|
||||||
|
});
|
||||||
|
m.setShares.mockImplementation(async (_id: number, ids: number[]) => ({
|
||||||
|
shares: ids.map((id) => ({ id, username: `u${id}`, name: `U${id}`, sharedAt: "" })),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceShareModal", () => {
|
||||||
|
it("starts on the current list, never offers the owner and says what sharing means", async () => {
|
||||||
|
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||||
|
await settle();
|
||||||
|
const list = screen.getByTestId("workspace-share-people");
|
||||||
|
expect(list).not.toHaveTextContent("Alice");
|
||||||
|
expect(screen.getByRole("checkbox", { name: /Bob/ })).toHaveAttribute("aria-checked", "true");
|
||||||
|
expect(screen.getByRole("checkbox", { name: /Carol/ })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"false"
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("workspace-share-warning")).toHaveTextContent(/your home folder/i);
|
||||||
|
// Nothing changed yet: nothing to save.
|
||||||
|
expect(screen.getByTestId("workspace-share-save")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks once before adding someone, then saves the whole list", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<WorkspaceShareModal lane={lane} onClose={onClose} />);
|
||||||
|
await settle();
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
|
||||||
|
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||||
|
await settle();
|
||||||
|
expect(m.setShares).not.toHaveBeenCalled();
|
||||||
|
await act(async () => settleConfirm(true));
|
||||||
|
await settle();
|
||||||
|
expect(m.setShares).toHaveBeenCalledWith(7, [2, 3]);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a collaborator disabled since, so they can be taken off", async () => {
|
||||||
|
m.shares.mockResolvedValue({
|
||||||
|
shares: [
|
||||||
|
{ id: 2, username: "bob", name: "Bob", sharedAt: "" },
|
||||||
|
{ id: 9, username: "dan", name: "Dan", sharedAt: "", disabled: true },
|
||||||
|
],
|
||||||
|
canManage: true,
|
||||||
|
});
|
||||||
|
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||||
|
await settle();
|
||||||
|
const dan = screen.getByRole("checkbox", { name: /Dan/ });
|
||||||
|
expect(dan).toHaveAttribute("aria-checked", "true");
|
||||||
|
expect(dan).toHaveTextContent("(disabled)");
|
||||||
|
fireEvent.click(dan);
|
||||||
|
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||||
|
await settle();
|
||||||
|
expect(m.setShares).toHaveBeenCalledWith(7, [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes someone off without asking", async () => {
|
||||||
|
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||||
|
await settle();
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /Bob/ }));
|
||||||
|
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||||
|
await settle();
|
||||||
|
expect(m.setShares).toHaveBeenCalledWith(7, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceRowMenu on a shared workspace", () => {
|
||||||
|
const open = () =>
|
||||||
|
render(<WorkspaceRowMenu lane={lane} x={0} y={0} onClose={() => {}} onAction={() => {}} />);
|
||||||
|
|
||||||
|
it("offers its owner Share, Rename and Delete", () => {
|
||||||
|
open();
|
||||||
|
expect(screen.getByTestId("workspace-menu-share")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Rename")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Delete workspace…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a user it is shared with none of the owner-only items", () => {
|
||||||
|
perms.me = 2;
|
||||||
|
open();
|
||||||
|
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
|
||||||
|
expect(screen.queryByText("Rename")).toBeNull();
|
||||||
|
expect(screen.queryByText("Delete workspace…")).toBeNull();
|
||||||
|
expect(screen.getByText("Copy path")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no Share in local mode", () => {
|
||||||
|
perms.local = true;
|
||||||
|
open();
|
||||||
|
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
|
||||||
|
expect(screen.getByText("Rename")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceShareModal for one session", () => {
|
||||||
|
it("reads and saves that session's own list, never the workspace's", async () => {
|
||||||
|
m.shares.mockClear();
|
||||||
|
m.setShares.mockClear();
|
||||||
|
m.sessionShares.mockResolvedValue({ shares: [], canManage: true });
|
||||||
|
m.setSessionShares.mockResolvedValue({
|
||||||
|
shares: [{ id: 3, username: "carol", name: "Carol", sharedAt: "" }],
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<WorkspaceShareModal
|
||||||
|
lane={lane}
|
||||||
|
session={{ id: "s-1", name: "Fix cart" }}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(m.sessionShares).toHaveBeenCalledWith(7, "s-1");
|
||||||
|
expect(m.shares).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("Share session")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Fix cart")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await settle();
|
||||||
|
await act(async () => settleConfirm(true));
|
||||||
|
await settle();
|
||||||
|
expect(m.setSessionShares).toHaveBeenCalledWith(7, "s-1", [3]);
|
||||||
|
expect(m.setShares).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,6 +85,8 @@ import {
|
|||||||
slotOfKey,
|
slotOfKey,
|
||||||
} from "../../lib/paneLayout";
|
} from "../../lib/paneLayout";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
import { useAuth } from "../AuthGate";
|
||||||
|
import { usePanePresence } from "../../hooks/usePanePresence";
|
||||||
|
|
||||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||||
|
|
||||||
@@ -234,6 +236,19 @@ export function ConsoleTabs({
|
|||||||
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const tabCtx = useContextMenu<Tab>();
|
const tabCtx = useContextMenu<Tab>();
|
||||||
|
// Server mode: who else has these panes open, and whose money a pane is.
|
||||||
|
const presence = usePanePresence(lane.id);
|
||||||
|
const { state: auth } = useAuth();
|
||||||
|
const meId = auth.mode === "server" ? auth.user?.id : undefined;
|
||||||
|
const othersIn = (tab: Tab) => {
|
||||||
|
const seen = new Map<number, { id: number; name: string }>();
|
||||||
|
for (const r of tab.runs) {
|
||||||
|
for (const u of presence[r.id] ?? []) if (u.id !== meId) seen.set(u.id, u);
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
};
|
||||||
|
const billedTo = (tab: Tab) =>
|
||||||
|
[...new Set(tab.runs.map((r) => r.startedBy?.name).filter(Boolean))] as string[];
|
||||||
|
|
||||||
// A group whose runs the list has not caught up with yet is not drawn.
|
// A group whose runs the list has not caught up with yet is not drawn.
|
||||||
const tabs = useMemo<Tab[]>(
|
const tabs = useMemo<Tab[]>(
|
||||||
@@ -532,6 +547,7 @@ export function ConsoleTabs({
|
|||||||
{tabs.map((tab, i) => {
|
{tabs.map((tab, i) => {
|
||||||
const active = !settings?.active && tab.index === groups.active;
|
const active = !settings?.active && tab.index === groups.active;
|
||||||
const single = tab.runs.length === 1 ? tab.focus : null;
|
const single = tab.runs.length === 1 ? tab.focus : null;
|
||||||
|
const others = othersIn(tab);
|
||||||
const state = tabState(tab);
|
const state = tabState(tab);
|
||||||
if (single && renaming === single.id) {
|
if (single && renaming === single.id) {
|
||||||
return (
|
return (
|
||||||
@@ -606,7 +622,7 @@ export function ConsoleTabs({
|
|||||||
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
|
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
|
||||||
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
|
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
|
||||||
onDoubleClick={() => single && startRename(single)}
|
onDoubleClick={() => single && startRename(single)}
|
||||||
title={
|
title={[
|
||||||
single
|
single
|
||||||
? [
|
? [
|
||||||
toolLabel(single),
|
toolLabel(single),
|
||||||
@@ -617,8 +633,14 @@ export function ConsoleTabs({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
|
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n"),
|
||||||
}
|
...billedTo(tab).map((name) => t("tabs.billedTo", { name })),
|
||||||
|
others.length
|
||||||
|
? t("tabs.here", { names: others.map((u) => u.name).join(", ") })
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")}
|
||||||
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
|
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
@@ -627,6 +649,23 @@ export function ConsoleTabs({
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{tabLabel(tab)}</span>
|
<span className="truncate">{tabLabel(tab)}</span>
|
||||||
|
{others.length > 0 && (
|
||||||
|
<span
|
||||||
|
data-testid={`console-tab-people-${tab.key}`}
|
||||||
|
className="flex shrink-0 -space-x-1"
|
||||||
|
aria-label={t("tabs.here", { names: others.map((u) => u.name).join(", ") })}
|
||||||
|
>
|
||||||
|
{others.slice(0, 3).map(({ id, name }) => (
|
||||||
|
<span
|
||||||
|
key={id}
|
||||||
|
aria-hidden
|
||||||
|
className="flex h-4 w-4 items-center justify-center rounded-full border border-surface-1 bg-accent-solid text-[9px] font-semibold uppercase text-white"
|
||||||
|
>
|
||||||
|
{name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{state && <RunDot state={state} />}
|
{state && <RunDot state={state} />}
|
||||||
</button>
|
</button>
|
||||||
{tab.runs.some(canSleep) && (
|
{tab.runs.some(canSleep) && (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ import {
|
|||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
import { wheelReports, type WheelState } from "../../lib/wheelReports";
|
import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
@@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
fontWeightBold: 700,
|
fontWeightBold: 700,
|
||||||
theme: paletteRef.current,
|
theme: paletteRef.current,
|
||||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||||
|
// xterm's own scrollback scrolls at the same share as the reports below.
|
||||||
|
scrollSensitivity: SCROLL_SPEED,
|
||||||
});
|
});
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
@@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
||||||
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
|
const col = clamp(
|
||||||
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
|
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
|
||||||
|
term.cols
|
||||||
|
);
|
||||||
|
const row = clamp(
|
||||||
|
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
|
||||||
|
term.rows
|
||||||
|
);
|
||||||
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
||||||
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @file ConsoleTabs.presence.test.tsx
|
||||||
|
* @description Workspace sharing phase 4 on the tab bar: a tab shows who ELSE
|
||||||
|
* has its panes open (never the viewer), and its tooltip says whose money a
|
||||||
|
* pane a collaborator opened is.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import "../../../i18n";
|
||||||
|
import type { RunHandle } from "../../../lib/api";
|
||||||
|
import type { Lane } from "../../../lib/types";
|
||||||
|
import { pane, type PaneGroups } from "../../../lib/paneLayout";
|
||||||
|
import { ConsoleTabs } from "../ConsoleTabs";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
aiTools: {
|
||||||
|
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||||
|
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../AuthGate", () => ({
|
||||||
|
useAuth: () => ({ state: { mode: "server", user: { id: 1, username: "alice" } } }),
|
||||||
|
usePermissions: () => ({ local: false, isAdmin: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hooks/usePanePresence", () => ({
|
||||||
|
usePanePresence: () => ({
|
||||||
|
"agenthub-lane-1": [
|
||||||
|
{ id: 1, name: "Alice" },
|
||||||
|
{ id: 2, name: "Bob" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
|
||||||
|
const RUN: RunHandle = {
|
||||||
|
id: "agenthub-lane-1",
|
||||||
|
laneId: 1,
|
||||||
|
slot: 0,
|
||||||
|
status: "running",
|
||||||
|
cwd: "/w",
|
||||||
|
model: null,
|
||||||
|
permissionMode: null,
|
||||||
|
effort: null,
|
||||||
|
resumeSessionId: null,
|
||||||
|
sessionId: null,
|
||||||
|
startedAt: null,
|
||||||
|
promptPreview: null,
|
||||||
|
startedBy: { id: 2, name: "Bob" },
|
||||||
|
tool: "claude",
|
||||||
|
};
|
||||||
|
const GROUPS: PaneGroups = { groups: [{ root: pane(0), focus: 0 }], active: 0 };
|
||||||
|
|
||||||
|
describe("ConsoleTabs presence", () => {
|
||||||
|
it("shows who else is in a pane, and whose money it is", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ConsoleTabs
|
||||||
|
lane={LANE}
|
||||||
|
tabs={[RUN]}
|
||||||
|
groups={GROUPS}
|
||||||
|
onGroupsChange={vi.fn()}
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onClosed={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const people = screen.getAllByTestId(/^console-tab-people-/)[0]!;
|
||||||
|
expect(people.textContent).toBe("B");
|
||||||
|
expect(people.getAttribute("aria-label")).toBe("Also here: Bob");
|
||||||
|
const tab = screen.getAllByRole("tab")[0]!;
|
||||||
|
expect(tab.getAttribute("title")).toContain("Billed to Bob");
|
||||||
|
expect(tab.getAttribute("title")).not.toContain("Alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { 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", () => {
|
||||||
|
|||||||
@@ -329,7 +329,8 @@ describe("TerminalView", () => {
|
|||||||
|
|
||||||
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||||
const screenBox = () => {
|
const screenBox = () => {
|
||||||
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
|
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
|
||||||
|
.value as { element: HTMLElement };
|
||||||
const screen = document.createElement("div");
|
const screen = document.createElement("div");
|
||||||
screen.className = "xterm-screen";
|
screen.className = "xterm-screen";
|
||||||
screen.getBoundingClientRect = () =>
|
screen.getBoundingClientRect = () =>
|
||||||
@@ -350,9 +351,11 @@ describe("TerminalView", () => {
|
|||||||
// A notch of 6 rows: one report, at the cell under the pointer.
|
// A notch of 6 rows: one report, at the cell under the pointer.
|
||||||
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||||||
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
||||||
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
|
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
|
||||||
|
// two reports, sent as they add up.
|
||||||
ws.sent.length = 0;
|
ws.sent.length = 0;
|
||||||
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
for (let i = 0; i < 6; i++)
|
||||||
|
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||||
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -523,9 +526,7 @@ describe("TerminalView", () => {
|
|||||||
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||||
// (xterm 6 removed the canvas one).
|
// (xterm 6 removed the canvas one).
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export const THEME_LABELS: Record<string, string> = {
|
|||||||
canvas: "Canvas",
|
canvas: "Canvas",
|
||||||
"orca-dark": "Orca Dark",
|
"orca-dark": "Orca Dark",
|
||||||
"orca-light": "Orca Light",
|
"orca-light": "Orca Light",
|
||||||
|
"sunset-cloud": "Sunset Cloud",
|
||||||
|
terminal: "Terminal",
|
||||||
|
"night-sea": "Night Sea",
|
||||||
|
sakura: "Sakura",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||||
|
|||||||
@@ -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,17 +24,32 @@ 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
|
||||||
|
label={t("installsSwitch")}
|
||||||
|
description={t("installsSwitchHint")}
|
||||||
|
checked={settings.installsEnabled}
|
||||||
|
onChange={async (v) => {
|
||||||
|
try {
|
||||||
|
const r = await api.artifacts.setInstallsEnabled(v);
|
||||||
|
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SettingsSwitchRow
|
<SettingsSwitchRow
|
||||||
label={t("installsSwitch")}
|
label={t("exportsSwitch")}
|
||||||
description={t("installsSwitchHint")}
|
description={t("exportsSwitchHint")}
|
||||||
checked={settings.installsEnabled}
|
checked={settings.exportsEnabled !== false}
|
||||||
onChange={async (v) => {
|
onChange={async (v) => {
|
||||||
try {
|
try {
|
||||||
const r = await api.artifacts.setInstallsEnabled(v);
|
const r = await api.artifacts.setExportsEnabled(v);
|
||||||
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
setSettings({ ...settings, exportsEnabled: r.exportsEnabled });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err);
|
toast.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
|
|
||||||
* mode, every user picks which account of the shared account pool their own
|
|
||||||
* new sessions run on, per tool, among the accounts their pool group may use
|
|
||||||
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
|
||||||
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
|
|
||||||
* Claude login goes into the user's home at once, so running panes follow it;
|
|
||||||
* Codex and Cursor take it at the next session. Renders nothing in local mode
|
|
||||||
* or when the pool holds no account the user may use.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Lock } from "lucide-react";
|
|
||||||
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
|
|
||||||
import { toast } from "../../lib/toast";
|
|
||||||
import { usePermissions } from "../AuthGate";
|
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
|
||||||
import { SettingsRow, SettingsSection } from "./SettingsRow";
|
|
||||||
|
|
||||||
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
|
||||||
|
|
||||||
export function PoolAccountSettings() {
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const { local } = usePermissions();
|
|
||||||
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
|
|
||||||
const [busy, setBusy] = useState<PoolTool | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (local) return;
|
|
||||||
api.me
|
|
||||||
.poolAccounts()
|
|
||||||
.then((r) => setTools(r.tools))
|
|
||||||
.catch(() => setTools(null));
|
|
||||||
}, [local]);
|
|
||||||
|
|
||||||
if (local || !tools?.length) return null;
|
|
||||||
|
|
||||||
const choose = async (tool: PoolTool, account: string | null) => {
|
|
||||||
setBusy(tool);
|
|
||||||
try {
|
|
||||||
const r = await api.me.choosePoolAccount(tool, account);
|
|
||||||
setTools((list) =>
|
|
||||||
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
|
|
||||||
);
|
|
||||||
toast.success(
|
|
||||||
!r.choice
|
|
||||||
? t("poolAccount.automaticSaved")
|
|
||||||
: r.switched === "switched" || r.switched === "kept"
|
|
||||||
? t("poolAccount.switchedNow", { account: r.choice })
|
|
||||||
: t("poolAccount.nextSession", { account: r.choice })
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err);
|
|
||||||
} finally {
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection
|
|
||||||
id="pool-account"
|
|
||||||
title={t("poolAccount.title")}
|
|
||||||
description={t("poolAccount.description")}
|
|
||||||
>
|
|
||||||
{tools.map((x) => (
|
|
||||||
<SettingsRow
|
|
||||||
key={x.tool}
|
|
||||||
id={`pool-account-${x.tool}`}
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
|
|
||||||
{LABEL[x.tool]}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
x.pin ? (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Lock className="h-3 w-3" />
|
|
||||||
{t("poolAccount.pinned", { account: x.pin })}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t("poolAccount.count", { count: x.accounts.length })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={!!x.pin}
|
|
||||||
control={
|
|
||||||
<select
|
|
||||||
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
|
|
||||||
value={x.pin ?? x.choice ?? ""}
|
|
||||||
disabled={!!x.pin || busy === x.tool}
|
|
||||||
onChange={(e) => void choose(x.tool, e.target.value || null)}
|
|
||||||
className="select !py-1 !text-xs max-w-[16rem]"
|
|
||||||
>
|
|
||||||
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
|
|
||||||
{x.accounts.map((a) => (
|
|
||||||
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
|
|
||||||
{[
|
|
||||||
a.name,
|
|
||||||
a.email && a.email !== a.name ? `(${a.email})` : null,
|
|
||||||
a.expired
|
|
||||||
? `— ${t("poolAccount.expired")}`
|
|
||||||
: a.full
|
|
||||||
? `— ${t("poolAccount.full")}`
|
|
||||||
: a.quotaPercent != null
|
|
||||||
? `— ${Math.round(a.quotaPercent)}%`
|
|
||||||
: null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* @file PoolProfiles — the shared account pool's accounts as rows of Settings →
|
||||||
|
* AI tools → Profiles (server mode). Each account this user's pool group may
|
||||||
|
* use is one row beside the saved profiles, plus an "Automatic" row; "Switch
|
||||||
|
* to" picks which account the user's OWN new sessions run on
|
||||||
|
* (`PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||||
|
* Automatic leaves it to the balancer; an admin's pin locks every row. A
|
||||||
|
* Claude login goes into the user's home at once, so running panes follow it;
|
||||||
|
* Codex and Cursor take it at the next session.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Lock } from "lucide-react";
|
||||||
|
import { api, type MyPoolTool } from "../../lib/api";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
|
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
|
||||||
|
const btnCls =
|
||||||
|
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
|
||||||
|
|
||||||
|
/** Lower-cased emails of a tool's pool accounts — a saved profile with one is the same account. */
|
||||||
|
export function poolEmails(entry: MyPoolTool | null | undefined): Set<string> {
|
||||||
|
return new Set(
|
||||||
|
(entry?.accounts ?? []).map((a) => a.email?.toLowerCase()).filter((e): e is string => !!e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PoolProfiles({
|
||||||
|
entry,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
entry: MyPoolTool;
|
||||||
|
onChange: (next: MyPoolTool) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const { t: tc } = useTranslation("ccConfig");
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const locked = !!entry.pin;
|
||||||
|
/** The account the user's new sessions run on now; null = Automatic. */
|
||||||
|
const current = entry.pin ?? entry.choice;
|
||||||
|
|
||||||
|
const choose = async (account: string | null) => {
|
||||||
|
setBusy(account ?? "");
|
||||||
|
try {
|
||||||
|
const r = await api.me.choosePoolAccount(entry.tool, account);
|
||||||
|
onChange({ ...entry, choice: r.choice });
|
||||||
|
toast.success(
|
||||||
|
!r.choice
|
||||||
|
? t("poolAccount.automaticSaved")
|
||||||
|
: r.switched === "switched" || r.switched === "kept"
|
||||||
|
? t("poolAccount.switchedNow", { account: r.choice })
|
||||||
|
: t("poolAccount.nextSession", { account: r.choice })
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inUse = (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||||
|
{t("poolAccount.inUse")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const poolBadge = (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-border bg-surface-3 text-fg-muted">
|
||||||
|
{t("poolAccount.badge")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const switchBtn = (account: string | null, disabled = false) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null || disabled}
|
||||||
|
onClick={() => void choose(account)}
|
||||||
|
className={btnCls}
|
||||||
|
>
|
||||||
|
{busy === (account ?? "") ? tc("profiles.switching") : tc("profiles.switchButton")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" data-testid={`pool-profiles-${entry.tool}`}>
|
||||||
|
{locked && (
|
||||||
|
<p className="text-[11px] text-fg-muted inline-flex items-center gap-1.5">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
{t("poolAccount.pinned", { account: entry.pin })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!locked && (
|
||||||
|
<div className={rowCls} data-testid={`pool-row-${entry.tool}-automatic`}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-medium text-fg-primary">
|
||||||
|
{t("poolAccount.automatic")}
|
||||||
|
</span>
|
||||||
|
{poolBadge}
|
||||||
|
{current === null && inUse}
|
||||||
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
|
{t("poolAccount.automaticHint")}
|
||||||
|
</span>
|
||||||
|
{current !== null && switchBtn(null)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.accounts.map((a) => {
|
||||||
|
const status = a.expired
|
||||||
|
? t("poolAccount.expired")
|
||||||
|
: a.full
|
||||||
|
? t("poolAccount.full")
|
||||||
|
: a.quotaPercent != null
|
||||||
|
? t("poolAccount.quota", { pct: Math.round(a.quotaPercent) })
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div key={a.name} className={rowCls} data-testid={`pool-row-${entry.tool}-${a.name}`}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-medium text-fg-primary">{a.name}</span>
|
||||||
|
{poolBadge}
|
||||||
|
{current === a.name && inUse}
|
||||||
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
|
{a.email && a.email !== a.name ? a.email : ""}
|
||||||
|
</span>
|
||||||
|
{!locked && current !== a.name && switchBtn(a.name, a.expired)}
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<div
|
||||||
|
className={`text-[10px] mt-0.5 ${
|
||||||
|
a.expired || a.full ? "text-status-danger" : "text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,14 +4,20 @@
|
|||||||
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
|
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
|
||||||
* warning — this panel used to be a modal on the Claude Code page); Codex and
|
* warning — this panel used to be a modal on the Claude Code page); Codex and
|
||||||
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
|
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
|
||||||
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
|
* (Antigravity: an OS-keyring login) says why. In server mode the shared
|
||||||
* each Claude/Codex profile to the account pool in Administration → Users,
|
* account pool's accounts this user may use (`GET /api/me/pool-accounts`) are
|
||||||
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
|
* rows of the same list (PoolProfiles: "Switch to" picks the account the
|
||||||
* pool's Add dialog on it), and shows how many accounts the pool holds.
|
* user's own new sessions run on, plus an Automatic row) — there is no
|
||||||
|
* separate Pool account section any more. A saved profile holding the same
|
||||||
|
* account (email) as a pool row is folded into that row. Staff also see the
|
||||||
|
* host's saved logins and how many accounts the whole pool holds; an admin can
|
||||||
|
* move a profile into the pool in Administration → Users
|
||||||
|
* (`?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). A
|
||||||
|
* developer sees only the pool rows of the tools they may use.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -33,11 +39,19 @@ import {
|
|||||||
type CcProfile,
|
type CcProfile,
|
||||||
type CcProfileUsage,
|
type CcProfileUsage,
|
||||||
type CcUsageWindow,
|
type CcUsageWindow,
|
||||||
|
type MyPoolTool,
|
||||||
type ToolProfile,
|
type ToolProfile,
|
||||||
type ToolProfileIdentity,
|
type ToolProfileIdentity,
|
||||||
type ToolProfilesTool,
|
type ToolProfilesTool,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
|
import {
|
||||||
|
profileKey,
|
||||||
|
readHiddenProfiles,
|
||||||
|
standIn,
|
||||||
|
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";
|
||||||
@@ -46,11 +60,14 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
|||||||
import { SkeletonRows } from "../config/configPrimitives";
|
import { SkeletonRows } from "../config/configPrimitives";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { SettingsSection } from "./SettingsRow";
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
import { PoolProfiles, poolEmails } from "./PoolProfiles";
|
||||||
|
|
||||||
/** The tools the panel covers, in tab order, with the command that signs one in. */
|
/** The tools the panel covers, in tab order, with the command that signs one in.
|
||||||
const TOOLS: { id: string; label: string; login: string }[] = [
|
* `poolOnly`: no saved logins of its own — a tab only while the pool has one. */
|
||||||
|
const TOOLS: { id: string; label: string; login: string; poolOnly?: boolean }[] = [
|
||||||
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
||||||
{ id: "codex", label: "Codex", login: "codex login" },
|
{ id: "codex", label: "Codex", login: "codex login" },
|
||||||
|
{ id: "cursor", label: "Cursor", login: "", poolOnly: true },
|
||||||
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
||||||
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
||||||
];
|
];
|
||||||
@@ -65,15 +82,21 @@ export function ToolProfilesSettings() {
|
|||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
const { local, seesAll } = usePermissions();
|
const { local, seesAll } = usePermissions();
|
||||||
|
const [tab, setTab] = useState<string>("claude");
|
||||||
|
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||||
|
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||||
|
/** Server mode: the pool accounts THIS user may use, per tool. */
|
||||||
|
const [mine, setMine] = useState<MyPoolTool[] | null>(null);
|
||||||
|
const mineOf = (tool: string) => mine?.find((x) => x.tool === tool) ?? null;
|
||||||
const shown = TOOLS.filter((x) => {
|
const shown = TOOLS.filter((x) => {
|
||||||
|
if (mineOf(x.id)) return true;
|
||||||
|
// The host's own logins are a staff-only read (a developer would get 403s).
|
||||||
|
if (!seesAll || x.poolOnly) return false;
|
||||||
const tool = aiTools.byId(x.id);
|
const tool = aiTools.byId(x.id);
|
||||||
if (x.id === "claude") return tool?.enabled !== false;
|
if (x.id === "claude") return tool?.enabled !== false;
|
||||||
return !!tool && tool.installed && tool.enabled !== false;
|
return !!tool && tool.installed && tool.enabled !== false;
|
||||||
});
|
});
|
||||||
const [tab, setTab] = useState<string>("claude");
|
|
||||||
const current = shown.find((x) => x.id === tab) ?? shown[0];
|
const current = shown.find((x) => x.id === tab) ?? shown[0];
|
||||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
|
||||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
|
||||||
|
|
||||||
const loadOthers = useCallback(
|
const loadOthers = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -84,9 +107,9 @@ export function ToolProfilesSettings() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadOthers();
|
if (seesAll) void loadOthers();
|
||||||
}, [loadOthers]);
|
}, [loadOthers, seesAll]);
|
||||||
// The pool is a server-mode, staff-only read.
|
// The whole pool is a server-mode, staff-only read.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (local || !seesAll) return;
|
if (local || !seesAll) return;
|
||||||
api.accountPool
|
api.accountPool
|
||||||
@@ -94,10 +117,23 @@ export function ToolProfilesSettings() {
|
|||||||
.then(setPool)
|
.then(setPool)
|
||||||
.catch(() => setPool(null));
|
.catch(() => setPool(null));
|
||||||
}, [local, seesAll]);
|
}, [local, seesAll]);
|
||||||
|
// The accounts this user may run on: server mode, every role.
|
||||||
|
useEffect(() => {
|
||||||
|
if (local) return;
|
||||||
|
api.me
|
||||||
|
.poolAccounts()
|
||||||
|
.then((r) => setMine(r.tools))
|
||||||
|
.catch(() => setMine([]));
|
||||||
|
}, [local]);
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
const poolCount = (tool: string) =>
|
const poolCount = (tool: string) =>
|
||||||
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
||||||
|
const entry = mineOf(current.id);
|
||||||
|
const setEntry = (next: MyPoolTool) =>
|
||||||
|
setMine((list) => (list ?? []).map((x) => (x.tool === next.tool ? next : x)));
|
||||||
|
const poolRows = entry ? <PoolProfiles entry={entry} onChange={setEntry} /> : null;
|
||||||
|
const merged = poolEmails(entry);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
@@ -125,11 +161,18 @@ export function ToolProfilesSettings() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div role="tabpanel" className="mt-3 space-y-3">
|
<div role="tabpanel" className="mt-3 space-y-3">
|
||||||
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
{seesAll && POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||||
<PoolLine count={poolCount(current.id)!} />
|
<PoolLine count={poolCount(current.id)!} />
|
||||||
)}
|
)}
|
||||||
{current.id === "claude" ? (
|
{!seesAll || current.poolOnly ? (
|
||||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
poolRows
|
||||||
|
) : current.id === "claude" ? (
|
||||||
|
<ClaudeProfiles
|
||||||
|
pooled={!local && POOL_TOOLS.has("claude")}
|
||||||
|
local={local}
|
||||||
|
merged={merged}
|
||||||
|
poolRows={poolRows}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<OtherProfiles
|
<OtherProfiles
|
||||||
key={current.id}
|
key={current.id}
|
||||||
@@ -139,6 +182,8 @@ export function ToolProfilesSettings() {
|
|||||||
state={others?.find((o) => o.tool === current.id) ?? null}
|
state={others?.find((o) => o.tool === current.id) ?? null}
|
||||||
loading={others === null}
|
loading={others === null}
|
||||||
pooled={!local && POOL_TOOLS.has(current.id)}
|
pooled={!local && POOL_TOOLS.has(current.id)}
|
||||||
|
merged={merged}
|
||||||
|
poolRows={poolRows}
|
||||||
onChanged={loadOthers}
|
onChanged={loadOthers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -288,6 +333,8 @@ function OtherProfiles({
|
|||||||
state,
|
state,
|
||||||
loading,
|
loading,
|
||||||
pooled,
|
pooled,
|
||||||
|
merged,
|
||||||
|
poolRows,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
tool: string;
|
tool: string;
|
||||||
@@ -296,6 +343,10 @@ function OtherProfiles({
|
|||||||
state: ToolProfilesTool | null;
|
state: ToolProfilesTool | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
pooled: boolean;
|
pooled: boolean;
|
||||||
|
/** Emails of the pool rows: a saved login of one is shown as that row. */
|
||||||
|
merged: Set<string>;
|
||||||
|
/** The pool accounts this user may use, as rows of this list. */
|
||||||
|
poolRows: ReactNode;
|
||||||
onChanged: () => Promise<unknown>;
|
onChanged: () => Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -313,8 +364,8 @@ function OtherProfiles({
|
|||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const items = state.items;
|
const activeSaved = state.items.some((p) => p.active);
|
||||||
const activeSaved = items.some((p) => p.active);
|
const items = state.items.filter((p) => !p.email || !merged.has(p.email.toLowerCase()));
|
||||||
const remove = async (p: ToolProfile) => {
|
const remove = async (p: ToolProfile) => {
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
||||||
@@ -369,7 +420,7 @@ function OtherProfiles({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 && (
|
{items.length === 0 && !poolRows && (
|
||||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||||
{tc("profiles.empty")}
|
{tc("profiles.empty")}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,6 +489,7 @@ function OtherProfiles({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{poolRows}
|
||||||
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -475,7 +527,19 @@ 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,
|
||||||
|
merged,
|
||||||
|
poolRows,
|
||||||
|
}: {
|
||||||
|
pooled: boolean;
|
||||||
|
local: boolean;
|
||||||
|
/** Emails of the pool rows: a saved profile of one is shown as that row. */
|
||||||
|
merged: Set<string>;
|
||||||
|
/** The pool accounts this user may use, as rows of this list. */
|
||||||
|
poolRows: ReactNode;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { 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 +556,51 @@ 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);
|
||||||
|
/** Shown as the live login while the real one is hidden: the first visible
|
||||||
|
* profile, with its own usage — a "—" there would only draw the eye. */
|
||||||
|
const decoy =
|
||||||
|
activeHidden && items
|
||||||
|
? standIn(
|
||||||
|
items.map((p) => ({ ...p, accountUuid: p.accountUuid ?? null })),
|
||||||
|
(p) => p.active || (!!active?.accountUuid && p.accountUuid === active.accountUuid),
|
||||||
|
hidden
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active);
|
||||||
|
/** Listed as its own row: not hidden by this browser, and not a pool account
|
||||||
|
* (that one is the pool's row — one account, one row). */
|
||||||
|
const listed = useCallback(
|
||||||
|
(p: CcProfile) => (reveal || !isHidden(p)) && !(p.email && merged.has(p.email.toLowerCase())),
|
||||||
|
[reveal, isHidden, merged]
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -540,10 +649,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 (!listed(p)) continue;
|
||||||
autoFetched.current.add(p.name);
|
autoFetched.current.add(p.name);
|
||||||
void checkUsage(p.name);
|
void checkUsage(p.name);
|
||||||
}
|
}
|
||||||
}, [items, checkUsage]);
|
}, [items, checkUsage, listed]);
|
||||||
|
|
||||||
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 +697,9 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
<span>
|
<span>
|
||||||
{t("profiles.mismatch", {
|
{t("profiles.mismatch", {
|
||||||
config: mismatchEmail,
|
config: mismatchEmail,
|
||||||
owner: active?.email || active?.accountUuid || "?",
|
owner: activeHidden
|
||||||
|
? decoy?.email || "?"
|
||||||
|
: active?.email || active?.accountUuid || "?",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -603,7 +715,18 @@ 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 ? (
|
||||||
|
decoy ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-fg-primary">
|
||||||
|
{decoy.email || decoy.displayName || decoy.accountUuid}
|
||||||
|
</div>
|
||||||
|
<IdentityLine identity={decoy} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||||
|
)
|
||||||
|
) : active ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-fg-primary">
|
<div className="text-sm text-fg-primary">
|
||||||
{active.email || active.displayName || active.accountUuid}
|
{active.email || active.displayName || active.accountUuid}
|
||||||
@@ -613,7 +736,7 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||||
)}
|
)}
|
||||||
{active && !activeSaved && (
|
{active && !activeSaved && !activeHidden && (
|
||||||
<div className="pt-1.5 space-y-1.5">
|
<div className="pt-1.5 space-y-1.5">
|
||||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||||
<button
|
<button
|
||||||
@@ -631,17 +754,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(listed).length === 0 && !poolRows && (
|
||||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
<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(listed).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
|
||||||
@@ -657,11 +781,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{p.active && <ActiveBadge />}
|
{shownActive(p) && <ActiveBadge />}
|
||||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
{p.email || p.displayName || p.accountUuid}
|
{p.email || p.displayName || p.accountUuid}
|
||||||
</span>
|
</span>
|
||||||
{!p.active && (
|
{!shownActive(p) && (
|
||||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
||||||
</button>
|
</button>
|
||||||
@@ -696,10 +820,12 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{poolRows}
|
||||||
|
|
||||||
{ctx.menu && (
|
{ctx.menu && (
|
||||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||||
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
|
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
|
||||||
{!ctx.menu.item.active && (
|
{!shownActive(ctx.menu.item) && !ctx.menu.item.active && (
|
||||||
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||||
{t("profiles.switchButton")}
|
{t("profiles.switchButton")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ describe("AppearanceSettings", () => {
|
|||||||
"Apple Light",
|
"Apple Light",
|
||||||
"Canvas",
|
"Canvas",
|
||||||
"Orca Light",
|
"Orca Light",
|
||||||
|
"Sunset Cloud",
|
||||||
|
"Terminal",
|
||||||
|
"Night Sea",
|
||||||
|
"Sakura",
|
||||||
]) {
|
]) {
|
||||||
expect(screen.getByText(name)).toBeInTheDocument();
|
expect(screen.getByText(name)).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
|
|
||||||
* hidden in local mode and with no usable account; one select per tool that
|
|
||||||
* starts on the user's choice (or Automatic) and saves a pick through
|
|
||||||
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, act } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import "../../../i18n";
|
|
||||||
import { PoolAccountSettings } from "../PoolAccountSettings";
|
|
||||||
|
|
||||||
const perms = vi.hoisted(() => ({ local: false }));
|
|
||||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
|
||||||
|
|
||||||
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
|
||||||
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const account = (name: string) => ({
|
|
||||||
name,
|
|
||||||
kind: "login",
|
|
||||||
email: `${name}@x.io`,
|
|
||||||
expired: false,
|
|
||||||
quotaPercent: 20,
|
|
||||||
full: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function show() {
|
|
||||||
render(<PoolAccountSettings />);
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise((res) => setTimeout(res, 0));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
perms.local = false;
|
|
||||||
m.poolAccounts.mockReset();
|
|
||||||
m.choosePoolAccount.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PoolAccountSettings", () => {
|
|
||||||
it("renders nothing in local mode or with no account to choose", async () => {
|
|
||||||
perms.local = true;
|
|
||||||
await show();
|
|
||||||
expect(m.poolAccounts).not.toHaveBeenCalled();
|
|
||||||
expect(screen.queryByRole("combobox")).toBeNull();
|
|
||||||
perms.local = false;
|
|
||||||
m.poolAccounts.mockResolvedValue({ tools: [] });
|
|
||||||
await show();
|
|
||||||
expect(screen.queryByRole("combobox")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts on the user's choice and saves a new pick", async () => {
|
|
||||||
m.poolAccounts.mockResolvedValue({
|
|
||||||
tools: [
|
|
||||||
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
|
|
||||||
await show();
|
|
||||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
||||||
expect(select.value).toBe("two");
|
|
||||||
await userEvent.selectOptions(select, "one");
|
|
||||||
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
|
||||||
await userEvent.selectOptions(select, "");
|
|
||||||
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is locked on an admin's pin", async () => {
|
|
||||||
m.poolAccounts.mockResolvedValue({
|
|
||||||
tools: [
|
|
||||||
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await show();
|
|
||||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
||||||
expect(select.disabled).toBe(true);
|
|
||||||
expect(select.value).toBe("one");
|
|
||||||
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,12 +4,16 @@
|
|||||||
* failing account costs the others nothing), and its right-click delete asks
|
* failing account costs the others nothing), and its right-click delete asks
|
||||||
* first and never offers to delete the only copy of the live account; Codex's
|
* first and never offers to delete the only copy of the live account; Codex's
|
||||||
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
|
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
|
||||||
* switched; and in server mode a profile links into the account pool.
|
* switched; and in server mode a profile links into the account pool, while
|
||||||
|
* the pool accounts the user may use are rows of the same list — "Switch to"
|
||||||
|
* picks one through `PUT /api/me/pool-accounts`, an Automatic row goes back to
|
||||||
|
* the balancer, an admin's pin locks them, a saved profile of the same email
|
||||||
|
* is folded into the pool row, and a developer sees only the pool rows.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, act } from "@testing-library/react";
|
import { render, screen, act, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import "../../../i18n";
|
import "../../../i18n";
|
||||||
@@ -38,6 +42,7 @@ const m = vi.hoisted(() => {
|
|||||||
},
|
},
|
||||||
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
||||||
pool: { list: vi.fn() },
|
pool: { list: vi.fn() },
|
||||||
|
me: { poolAccounts: vi.fn(), choosePoolAccount: vi.fn() },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
@@ -45,6 +50,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
ccConfig: m.cc,
|
ccConfig: m.cc,
|
||||||
toolProfiles: m.tp,
|
toolProfiles: m.tp,
|
||||||
accountPool: m.pool,
|
accountPool: m.pool,
|
||||||
|
me: m.me,
|
||||||
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -68,7 +74,9 @@ async function show() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
perms.local = true;
|
perms.local = true;
|
||||||
|
perms.seesAll = true;
|
||||||
m.cc.profiles.mockResolvedValue({
|
m.cc.profiles.mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||||
@@ -105,6 +113,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
||||||
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
||||||
|
m.me.poolAccounts.mockResolvedValue({ tools: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ToolProfilesSettings — Claude", () => {
|
describe("ToolProfilesSettings — Claude", () => {
|
||||||
@@ -137,6 +146,47 @@ 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("shows the first visible profile as the live login while the live one is hidden", async () => {
|
||||||
|
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u1"]));
|
||||||
|
await show();
|
||||||
|
await settle();
|
||||||
|
expect(screen.queryByTestId("profile-row-work")).toBeNull();
|
||||||
|
expect(screen.queryByText("a@b.c")).toBeNull();
|
||||||
|
expect(screen.queryByText("—")).toBeNull();
|
||||||
|
const spare = screen.getByTestId("profile-row-spare");
|
||||||
|
expect(spare).toHaveTextContent(/active/);
|
||||||
|
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
|
||||||
|
expect(screen.getAllByText("d@e.f").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hide anything in server mode", async () => {
|
||||||
|
perms.local = false;
|
||||||
|
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||||
|
await show();
|
||||||
|
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ToolProfilesSettings — other tools", () => {
|
describe("ToolProfilesSettings — other tools", () => {
|
||||||
@@ -176,3 +226,96 @@ describe("ToolProfilesSettings — account pool", () => {
|
|||||||
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const poolAccount = (name: string, email: string | null = `${name}@x.io`) => ({
|
||||||
|
name,
|
||||||
|
kind: "login",
|
||||||
|
email,
|
||||||
|
expired: false,
|
||||||
|
quotaPercent: 20,
|
||||||
|
full: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ToolProfilesSettings — pool accounts as profiles", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
perms.local = false;
|
||||||
|
m.me.poolAccounts.mockResolvedValue({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
tool: "claude",
|
||||||
|
pin: null,
|
||||||
|
choice: "two",
|
||||||
|
accounts: [poolAccount("one", "d@e.f"), poolAccount("two")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
m.me.choosePoolAccount.mockImplementation(async (tool: string, account: string | null) => ({
|
||||||
|
tool,
|
||||||
|
choice: account,
|
||||||
|
switched: account ? "switched" : null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the pool accounts beside the saved profiles, one row per account", async () => {
|
||||||
|
await show();
|
||||||
|
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("Quota 20%");
|
||||||
|
expect(screen.getByTestId("pool-row-claude-two")).toHaveTextContent("in use");
|
||||||
|
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
|
||||||
|
// "spare" is d@e.f — the pool's "one": shown once, as the pool row.
|
||||||
|
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||||
|
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches the user's own sessions to a pool account, and back to automatic", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await show();
|
||||||
|
const one = screen.getByTestId("pool-row-claude-one");
|
||||||
|
await user.click(within(one).getByRole("button", { name: "Switch to" }));
|
||||||
|
await settle();
|
||||||
|
expect(m.me.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||||
|
expect(m.cc.activateProfile).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
|
||||||
|
const auto = screen.getByTestId("pool-row-claude-automatic");
|
||||||
|
await user.click(within(auto).getByRole("button", { name: "Switch to" }));
|
||||||
|
await settle();
|
||||||
|
expect(m.me.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||||
|
expect(screen.getByTestId("pool-row-claude-automatic")).toHaveTextContent("in use");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks every pool row on an admin's pin", async () => {
|
||||||
|
m.me.poolAccounts.mockResolvedValue({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
tool: "claude",
|
||||||
|
pin: "one",
|
||||||
|
choice: null,
|
||||||
|
accounts: [poolAccount("one"), poolAccount("two")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await show();
|
||||||
|
expect(screen.getByText(/pinned you to one/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("pool-row-claude-automatic")).toBeNull();
|
||||||
|
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("pool-row-claude-two")).queryByRole("button", { name: "Switch to" })
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a developer only the pool rows, never the host's logins", async () => {
|
||||||
|
perms.seesAll = false;
|
||||||
|
await show();
|
||||||
|
expect(m.cc.profiles).not.toHaveBeenCalled();
|
||||||
|
expect(m.tp.list).not.toHaveBeenCalled();
|
||||||
|
expect(m.pool.list).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId("pool-row-claude-one")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("tab", { name: /codex/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not shown to a developer with no pool account to use", async () => {
|
||||||
|
perms.seesAll = false;
|
||||||
|
m.me.poolAccounts.mockResolvedValue({ tools: [] });
|
||||||
|
await show();
|
||||||
|
expect(screen.queryByRole("tablist")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,19 @@ describe("useTheme", () => {
|
|||||||
expect(document.documentElement.dataset.theme).toBe("orca-light");
|
expect(document.documentElement.dataset.theme).toBe("orca-light");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies the ai-chat-themes palettes by id, each in its own mode only", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
for (const name of ["sunset-cloud", "terminal", "night-sea"]) {
|
||||||
|
act(() => result.current.setThemeFor("dark", name));
|
||||||
|
expect(document.documentElement.dataset.theme).toBe(name);
|
||||||
|
}
|
||||||
|
act(() => result.current.setThemeFor("dark", "sakura"));
|
||||||
|
expect(result.current.themeFor.dark).toBe("night-sea");
|
||||||
|
act(() => result.current.setThemeFor("light", "sakura"));
|
||||||
|
act(() => result.current.toggleTheme());
|
||||||
|
expect(document.documentElement.dataset.theme).toBe("sakura");
|
||||||
|
});
|
||||||
|
|
||||||
it("the default theme carries no data-theme attribute", () => {
|
it("the default theme carries no data-theme attribute", () => {
|
||||||
const { result } = renderHook(() => useTheme());
|
const { result } = renderHook(() => useTheme());
|
||||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||||
|
|||||||
@@ -53,12 +53,16 @@ function load(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMessage(msg: WSMessage) {
|
function onMessage(msg: WSMessage) {
|
||||||
|
// A workspace shared with this user, or taken back: re-read the list.
|
||||||
|
if (msg.type === "lane_access") return void load();
|
||||||
if (msg.type !== "lane_update") return;
|
if (msg.type !== "lane_update") return;
|
||||||
const payload = msg.data as { lane?: Lane; removed?: number };
|
const payload = msg.data as { lane?: Lane; removed?: number };
|
||||||
const lane = payload.lane;
|
const lane = payload.lane;
|
||||||
if (payload.removed !== undefined || !lane || !lanes) return void load();
|
if (payload.removed !== undefined || !lane || !lanes) return void load();
|
||||||
const i = lanes.findIndex((l) => l.id === lane.id);
|
const i = lanes.findIndex((l) => l.id === lane.id);
|
||||||
if (i === -1) return void load();
|
// A broadcast carries no viewer's `access`: a lane seen through a shared
|
||||||
|
// session re-reads it (its access may have changed too).
|
||||||
|
if (i === -1 || lanes[i]?.access) return void load();
|
||||||
const next = [...lanes];
|
const next = [...lanes];
|
||||||
next[i] = lane;
|
next[i] = lane;
|
||||||
publish(next);
|
publish(next);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* @file usePanePresence.ts
|
||||||
|
* @description Who has each of a workspace's panes open (server mode, workspace
|
||||||
|
* sharing phase 4): `GET /api/lanes/:id/presence` once, then every
|
||||||
|
* `pane_presence` frame for that workspace, and a re-read on a WebSocket
|
||||||
|
* reconnect (frames missed while away are never replayed). Local mode has
|
||||||
|
* one user and reads nothing. Returns `{runId: [{id, name}]}`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { eventBus } from "../lib/eventBus";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
|
|
||||||
|
export type PanePeople = Record<string, { id: number; name: string }[]>;
|
||||||
|
|
||||||
|
export function usePanePresence(laneId: number | null | undefined): PanePeople {
|
||||||
|
const { local } = usePermissions();
|
||||||
|
const [panes, setPanes] = useState<PanePeople>({});
|
||||||
|
useEffect(() => {
|
||||||
|
setPanes({});
|
||||||
|
if (local || laneId == null) return;
|
||||||
|
let alive = true;
|
||||||
|
const load = () =>
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => api.lanes.presence(laneId))
|
||||||
|
.then((r) => alive && setPanes(r.panes))
|
||||||
|
.catch(() => {
|
||||||
|
/* presence is a nicety: no list, no chips */
|
||||||
|
});
|
||||||
|
void load();
|
||||||
|
const offMsg = eventBus.subscribe((msg) => {
|
||||||
|
if (msg.type !== "pane_presence") return;
|
||||||
|
const d = msg.data as { laneId?: number; runId?: string; users?: PanePeople[string] };
|
||||||
|
if (d.laneId !== laneId || !d.runId) return;
|
||||||
|
setPanes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (d.users?.length) next[d.runId!] = d.users;
|
||||||
|
else delete next[d.runId!];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const offConn = eventBus.onConnection((up) => {
|
||||||
|
if (up) void load();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
offMsg();
|
||||||
|
offConn();
|
||||||
|
};
|
||||||
|
}, [laneId, local]);
|
||||||
|
return panes;
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ export type ThemeMode = Theme | "system";
|
|||||||
|
|
||||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||||
export const THEMES = {
|
export const THEMES = {
|
||||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
dark: ["default", "darcula", "apple-dark", "orca-dark", "sunset-cloud", "terminal", "night-sea"],
|
||||||
light: ["default", "canvas", "apple-light", "orca-light"],
|
light: ["default", "canvas", "apple-light", "orca-light", "sakura"],
|
||||||
} as const satisfies Record<Theme, readonly string[]>;
|
} as const satisfies Record<Theme, readonly string[]>;
|
||||||
|
|
||||||
const MODE_KEY = "theme";
|
const MODE_KEY = "theme";
|
||||||
|
|||||||
@@ -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. Drag an artifact onto the folder, or use ⋯ → Move to folder.",
|
||||||
|
"folders.move": "Move to folder…",
|
||||||
|
"folders.moveTitle": "Move “{{title}}” to a folder",
|
||||||
|
"folders.private": "Your folders are yours alone: nobody else sees them.",
|
||||||
|
"folders.createHere": "New folder",
|
||||||
|
"folders.takeOut": "Take it out of its folder",
|
||||||
|
"page.view": "View",
|
||||||
|
"page.viewHint": "Its copy, viewable here without the claude.ai account that published it",
|
||||||
|
"page.noCopy": "No viewable copy: the file this page was published from is gone, or lies outside this workspace and its session's scratchpad. Open it on claude.ai instead.",
|
||||||
|
"pdf.page": "Page {{n}}",
|
||||||
|
"pdf.failed": "This PDF could not be shown. Download it instead.",
|
||||||
|
"pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.",
|
||||||
|
"exportsSwitch": "Keep a viewable copy of Docs and Slides",
|
||||||
|
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards.",
|
||||||
|
"folders.moved": "Moved to “{{name}}”",
|
||||||
|
"folders.takenOut": "Taken out of its folder",
|
||||||
|
"folders.dropHint": "Drop an artifact here to put it in this folder",
|
||||||
|
"folders.dropOutHint": "Drop an artifact here to take it out of its folder"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,12 @@
|
|||||||
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
|
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
|
||||||
"agenthub": "AgentHub",
|
"agenthub": "AgentHub",
|
||||||
"usersTitle": "Users · CPU / RAM now",
|
"usersTitle": "Users · CPU / RAM now",
|
||||||
"usersCount": "{{n}} users"
|
"usersCount": "{{n}} users",
|
||||||
|
"userTipRole": "Role: {{v}}",
|
||||||
|
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
|
||||||
|
"userTipWork": "{{w}} workspaces · {{s}} sessions",
|
||||||
|
"userTipLastLogin": "Last sign-in: {{v}}",
|
||||||
|
"userTipNever": "never",
|
||||||
|
"userTipDisabled": "Disabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
"gitBar.pushHint": "git push -u origin HEAD",
|
"gitBar.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",
|
||||||
@@ -384,6 +386,32 @@
|
|||||||
"menu.copyPath": "Copy path",
|
"menu.copyPath": "Copy path",
|
||||||
"menu.reset": "Reset worktree…",
|
"menu.reset": "Reset worktree…",
|
||||||
"menu.remove": "Delete workspace…",
|
"menu.remove": "Delete workspace…",
|
||||||
|
"menu.share": "Share…",
|
||||||
|
"share.title": "Share workspace",
|
||||||
|
"share.what": "People you share it with see this workspace and its sessions, join its open terminals, type into them and commit — like you. Only you rename, reset, delete or share it. A session they start runs on their own account and is billed to them.",
|
||||||
|
"share.warning": "They work as YOUR account on this server: they can read everything in your home folder (your other workspaces, SSH keys and AI logins) and their commits carry your name. Share only with people you would hand your keyboard to.",
|
||||||
|
"share.loading": "Loading…",
|
||||||
|
"share.nobody": "There is no one else to share with.",
|
||||||
|
"share.search": "Search people",
|
||||||
|
"share.cancel": "Cancel",
|
||||||
|
"share.close": "Close",
|
||||||
|
"share.save": "Save",
|
||||||
|
"share.saving": "Saving…",
|
||||||
|
"share.confirmTitle": "Share \"{{name}}\"?",
|
||||||
|
"share.confirmBody": "{{people}} will work in it as your account and can read everything in your home folder.",
|
||||||
|
"share.confirm": "Share",
|
||||||
|
"share.saved_one": "Shared with {{count}} person",
|
||||||
|
"share.saved_other": "Shared with {{count}} people",
|
||||||
|
"share.disabled": "(disabled)",
|
||||||
|
"share.savedNone": "No longer shared with anyone",
|
||||||
|
"shared.ownAccount": "This workspace is shared with you: a session you start here runs on your own account — a pool account or an AI Proxy of yours — and is billed to you.",
|
||||||
|
"share.sessionTitle": "Share session",
|
||||||
|
"share.sessionWhat": "People you share it with see this session and the workspace's changes, join its open tab, type into it, send it messages (on their own account, billed to them) and commit — nothing else of the workspace. Only you share it. On this server they work as YOUR account inside its terminal, so they can still reach the rest of your home from there.",
|
||||||
|
"sessionMenu.share": "Share…",
|
||||||
|
"shared.sessionOnly": "Only a session of this workspace is shared with you: open it from the list to work in it.",
|
||||||
|
"tabs.billedTo": "Billed to {{name}}",
|
||||||
|
"tabs.here": "Also here: {{names}}",
|
||||||
|
"shared.badge": "Shared with you",
|
||||||
"panel.changes": "Changes",
|
"panel.changes": "Changes",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
"panel.preview": "Preview",
|
"panel.preview": "Preview",
|
||||||
@@ -748,7 +776,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": {
|
||||||
@@ -174,23 +175,6 @@
|
|||||||
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||||
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||||
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"label": "Chat with {{name}}",
|
|
||||||
"close": "Close {{name}}",
|
|
||||||
"new": "New chat",
|
|
||||||
"status": "Status — click to show the sessions that need you",
|
|
||||||
"endTitle": "End this chat?",
|
|
||||||
"endBody": "It is still writing a reply. A new chat stops this session.",
|
|
||||||
"endConfirm": "Stop and start new",
|
|
||||||
"openWorkspace": "Open in workspace",
|
|
||||||
"typing": "typing…",
|
|
||||||
"placeholder": "Ask {{name}} anything…",
|
|
||||||
"emptyTitle": "Hi! I'm {{name}} 🐾",
|
|
||||||
"emptyHint": "Ask me anything — I'll get {{tool}} to answer right here.",
|
|
||||||
"starting": "Starting {{tool}}…",
|
|
||||||
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
|
||||||
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
@@ -1169,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",
|
||||||
@@ -1449,17 +1438,60 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"poolAccount": {
|
"poolAccount": {
|
||||||
"title": "Pool account",
|
|
||||||
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
|
|
||||||
"automatic": "Automatic",
|
"automatic": "Automatic",
|
||||||
"select": "{{tool}} pool account",
|
|
||||||
"count_one": "{{count}} account you may use",
|
|
||||||
"count_other": "{{count}} accounts you may use",
|
|
||||||
"pinned": "An admin pinned you to {{account}}",
|
"pinned": "An admin pinned you to {{account}}",
|
||||||
"expired": "expired",
|
"expired": "expired",
|
||||||
"full": "quota full",
|
"full": "quota full",
|
||||||
"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",
|
||||||
|
"inUse": "in use",
|
||||||
|
"badge": "Pool",
|
||||||
|
"automaticHint": "Your new sessions run on the least-used pool account",
|
||||||
|
"quota": "Quota {{pct}}%"
|
||||||
|
},
|
||||||
|
"companyHub": {
|
||||||
|
"title": "Company hub",
|
||||||
|
"description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.",
|
||||||
|
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first.",
|
||||||
|
"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. Kéo artifact thả vào thư mục, hoặc dùng ⋯ → Chuyển vào thư mục.",
|
||||||
|
"folders.move": "Chuyển vào thư mục…",
|
||||||
|
"folders.moveTitle": "Chuyển “{{title}}” vào thư mục",
|
||||||
|
"folders.private": "Thư mục là của riêng bạn: người khác không thấy.",
|
||||||
|
"folders.createHere": "Thư mục mới",
|
||||||
|
"folders.takeOut": "Bỏ khỏi thư mục",
|
||||||
|
"page.view": "Xem",
|
||||||
|
"page.viewHint": "Bản sao xem được ngay tại đây, không cần tài khoản claude.ai đã tạo trang",
|
||||||
|
"page.noCopy": "Chưa có bản xem: file gốc của trang này không còn, hoặc nằm ngoài workspace và scratchpad của phiên. Hãy mở trên claude.ai.",
|
||||||
|
"pdf.page": "Trang {{n}}",
|
||||||
|
"pdf.failed": "Không hiển thị được PDF này. Hãy tải về.",
|
||||||
|
"pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.",
|
||||||
|
"exportsSwitch": "Giữ bản xem được của Docs và Slides",
|
||||||
|
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó.",
|
||||||
|
"folders.moved": "Đã chuyển vào “{{name}}”",
|
||||||
|
"folders.takenOut": "Đã bỏ khỏi thư mục",
|
||||||
|
"folders.dropHint": "Thả artifact vào đây để đưa vào thư mục này",
|
||||||
|
"folders.dropOutHint": "Thả artifact vào đây để bỏ khỏi thư mục"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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ả"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,12 @@
|
|||||||
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
|
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
|
||||||
"agenthub": "AgentHub",
|
"agenthub": "AgentHub",
|
||||||
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
||||||
"usersCount": "{{n}} người dùng"
|
"usersCount": "{{n}} người dùng",
|
||||||
|
"userTipRole": "Vai trò: {{v}}",
|
||||||
|
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
|
||||||
|
"userTipWork": "{{w}} workspace · {{s}} phiên",
|
||||||
|
"userTipLastLogin": "Đăng nhập gần nhất: {{v}}",
|
||||||
|
"userTipNever": "chưa bao giờ",
|
||||||
|
"userTipDisabled": "Đã vô hiệu hóa"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -384,6 +386,32 @@
|
|||||||
"menu.copyPath": "Sao chép đường dẫn",
|
"menu.copyPath": "Sao chép đường dẫn",
|
||||||
"menu.reset": "Đặt lại worktree…",
|
"menu.reset": "Đặt lại worktree…",
|
||||||
"menu.remove": "Xóa workspace…",
|
"menu.remove": "Xóa workspace…",
|
||||||
|
"menu.share": "Chia sẻ…",
|
||||||
|
"share.title": "Chia sẻ workspace",
|
||||||
|
"share.what": "Người được chia sẻ thấy workspace này và các session của nó, vào được terminal đang mở, gõ và commit — như bạn. Chỉ bạn được đổi tên, đặt lại, xoá hay chia sẻ nó. Session họ mở chạy bằng tài khoản của chính họ và tính tiền cho họ.",
|
||||||
|
"share.warning": "Họ làm việc bằng TÀI KHOẢN CỦA BẠN trên server này: đọc được mọi thứ trong thư mục home của bạn (các workspace khác, SSH key, đăng nhập AI) và commit của họ mang tên bạn. Chỉ chia sẻ với người bạn tin như đưa bàn phím cho họ.",
|
||||||
|
"share.loading": "Đang tải…",
|
||||||
|
"share.nobody": "Không còn ai khác để chia sẻ.",
|
||||||
|
"share.search": "Tìm người",
|
||||||
|
"share.cancel": "Huỷ",
|
||||||
|
"share.close": "Đóng",
|
||||||
|
"share.save": "Lưu",
|
||||||
|
"share.saving": "Đang lưu…",
|
||||||
|
"share.confirmTitle": "Chia sẻ \"{{name}}\"?",
|
||||||
|
"share.confirmBody": "{{people}} sẽ làm việc trong đó bằng tài khoản của bạn và đọc được mọi thứ trong thư mục home của bạn.",
|
||||||
|
"share.confirm": "Chia sẻ",
|
||||||
|
"share.saved_one": "Đã chia sẻ với {{count}} người",
|
||||||
|
"share.saved_other": "Đã chia sẻ với {{count}} người",
|
||||||
|
"share.disabled": "(đã vô hiệu hoá)",
|
||||||
|
"share.savedNone": "Không còn chia sẻ với ai",
|
||||||
|
"shared.ownAccount": "Workspace này được chia sẻ với bạn: session bạn mở ở đây chạy bằng tài khoản của chính bạn — tài khoản pool hoặc AI Proxy của bạn — và tính tiền cho bạn.",
|
||||||
|
"share.sessionTitle": "Chia sẻ session",
|
||||||
|
"share.sessionWhat": "Người được chia sẻ thấy session này và các thay đổi của workspace, vào tab đang mở, gõ, gửi tin nhắn cho nó (bằng tài khoản của họ, tính tiền cho họ) và commit — không gì khác của workspace. Chỉ bạn chia sẻ được. Trên server này họ làm việc bằng TÀI KHOẢN CỦA BẠN trong terminal của nó, nên từ đó họ vẫn với tới được phần còn lại trong home của bạn.",
|
||||||
|
"sessionMenu.share": "Chia sẻ…",
|
||||||
|
"shared.sessionOnly": "Chỉ một session của workspace này được chia sẻ với bạn: mở nó từ danh sách để làm việc.",
|
||||||
|
"tabs.billedTo": "Tính tiền cho {{name}}",
|
||||||
|
"tabs.here": "Cũng đang mở: {{names}}",
|
||||||
|
"shared.badge": "Được chia sẻ với bạn",
|
||||||
"panel.changes": "Thay đổi",
|
"panel.changes": "Thay đổi",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
"panel.preview": "Xem trang",
|
"panel.preview": "Xem trang",
|
||||||
@@ -748,7 +776,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": {
|
||||||
@@ -162,23 +163,6 @@
|
|||||||
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
"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 🐈"],
|
"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 ✨"]
|
"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."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
@@ -1157,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ủ",
|
||||||
@@ -1437,17 +1426,60 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"poolAccount": {
|
"poolAccount": {
|
||||||
"title": "Tài khoản pool",
|
|
||||||
"description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.",
|
|
||||||
"automatic": "Tự động",
|
"automatic": "Tự động",
|
||||||
"select": "Tài khoản pool của {{tool}}",
|
|
||||||
"count_one": "{{count}} tài khoản bạn được dùng",
|
|
||||||
"count_other": "{{count}} tài khoản bạn được dùng",
|
|
||||||
"pinned": "Admin đã ghim bạn vào {{account}}",
|
"pinned": "Admin đã ghim bạn vào {{account}}",
|
||||||
"expired": "hết hạn",
|
"expired": "hết hạn",
|
||||||
"full": "hết quota",
|
"full": "hết quota",
|
||||||
"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",
|
||||||
|
"inUse": "đang dùng",
|
||||||
|
"badge": "Pool",
|
||||||
|
"automaticHint": "Phiên mới của bạn chạy trên tài khoản pool ít dùng nhất",
|
||||||
|
"quota": "Quota {{pct}}%"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -350,6 +350,106 @@
|
|||||||
--syn-del: 197 0 15; /* #c5000f */
|
--syn-del: 197 0 15; /* #c5000f */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sunset Cloud, Terminal, Night Sea (dark) and Sakura (light) — the colour
|
||||||
|
* themes of ai-chat-themes (github.com/Aohkne/ai-chat-themes, MIT), which
|
||||||
|
* gives only a background, a text and an accent colour per theme. Every
|
||||||
|
* other token is derived from those three and held to the bar above: text
|
||||||
|
* ≥ 4.5:1 on a card and on its hover lift, the text accent ≥ 4.5:1 on a card
|
||||||
|
* and on its own /15 chip (Sakura's #e0567f and Sunset's #ff8a6b darkened /
|
||||||
|
* lightened to get there), white ≥ 4.5:1 on `--accent-solid`, each status
|
||||||
|
* label ≥ 4.5:1 on its /20 chip. Their fonts (a monospace UI, Georgia) are
|
||||||
|
* not taken; syntax, diff and `--kept` colours are their mode's defaults.
|
||||||
|
* `--ansi-blue` (read only by `lib/terminalTheme.ts`) keeps a terminal's
|
||||||
|
* blue blue where the accent is green or coral. */
|
||||||
|
.dark[data-theme="sunset-cloud"] {
|
||||||
|
--surface-0: 32 24 36; /* #201824 */
|
||||||
|
--surface-1: 48 37 52; /* #302534 */
|
||||||
|
--surface-2: 54 43 58; /* #362b3a */
|
||||||
|
--surface-3: 60 49 63; /* #3c313f */
|
||||||
|
--surface-4: 67 56 69; /* #433845 */
|
||||||
|
--surface-5: 75 63 76; /* #4b3f4c */
|
||||||
|
--border: 76 64 76; /* #4c404c */
|
||||||
|
--border-light: 88 76 87; /* #584c57 */
|
||||||
|
--accent: 255 159 134; /* #ff9f86 */
|
||||||
|
--accent-hover: 255 188 170; /* #ffbcaa */
|
||||||
|
--accent-solid: 173 94 73; /* #ad5e49 */
|
||||||
|
--accent-solid-hover: 149 81 63; /* #95513f */
|
||||||
|
--fg-primary: 247 232 226; /* #f7e8e2 */
|
||||||
|
--fg-secondary: 214 203 203; /* #d6cbcb */
|
||||||
|
--fg-muted: 176 165 168; /* #b0a5a8 */
|
||||||
|
--status-success: 88 220 156; /* #58dc9c */
|
||||||
|
--status-danger: 255 176 173; /* #ffb0ad */
|
||||||
|
--status-warning: 255 202 22; /* #ffca16 */
|
||||||
|
/* ANSI blue in the terminal: the accent is not blue here. */
|
||||||
|
--ansi-blue: 112 184 255; /* #70b8ff */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme="terminal"] {
|
||||||
|
--surface-0: 8 13 9; /* #080d09 */
|
||||||
|
--surface-1: 14 23 17; /* #0e1711 */
|
||||||
|
--surface-2: 20 30 22; /* #141e16 */
|
||||||
|
--surface-3: 25 36 28; /* #19241c */
|
||||||
|
--surface-4: 31 44 34; /* #1f2c22 */
|
||||||
|
--surface-5: 37 52 41; /* #253429 */
|
||||||
|
--border: 38 54 42; /* #26362a */
|
||||||
|
--border-light: 49 67 53; /* #314335 */
|
||||||
|
--accent: 57 232 122; /* #39e87a */
|
||||||
|
--accent-hover: 116 239 162; /* #74efa2 */
|
||||||
|
--accent-solid: 32 132 70; /* #208446 */
|
||||||
|
--accent-solid-hover: 28 114 60; /* #1c723c */
|
||||||
|
--fg-primary: 185 246 198; /* #b9f6c6 */
|
||||||
|
--fg-secondary: 147 196 157; /* #93c49d */
|
||||||
|
--fg-muted: 117 154 125; /* #759a7d */
|
||||||
|
--status-success: 61 214 140; /* #3dd68c */
|
||||||
|
--status-danger: 255 149 146; /* #ff9592 */
|
||||||
|
--status-warning: 255 202 22; /* #ffca16 */
|
||||||
|
/* ANSI blue in the terminal: the accent is not blue here. */
|
||||||
|
--ansi-blue: 112 184 255; /* #70b8ff */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme="night-sea"] {
|
||||||
|
--surface-0: 11 20 33; /* #0b1421 */
|
||||||
|
--surface-1: 20 31 49; /* #141f31 */
|
||||||
|
--surface-2: 26 37 55; /* #1a2537 */
|
||||||
|
--surface-3: 32 43 62; /* #202b3e */
|
||||||
|
--surface-4: 40 50 69; /* #283245 */
|
||||||
|
--surface-5: 47 58 76; /* #2f3a4c */
|
||||||
|
--border: 48 59 77; /* #303b4d */
|
||||||
|
--border-light: 60 71 89; /* #3c4759 */
|
||||||
|
--accent: 92 175 255; /* #5cafff */
|
||||||
|
--accent-hover: 141 199 255; /* #8dc7ff */
|
||||||
|
--accent-solid: 62 120 176; /* #3e78b0 */
|
||||||
|
--accent-solid-hover: 53 103 151; /* #356797 */
|
||||||
|
--fg-primary: 220 230 250; /* #dce6fa */
|
||||||
|
--fg-secondary: 183 192 210; /* #b7c0d2 */
|
||||||
|
--fg-muted: 146 155 173; /* #929bad */
|
||||||
|
--status-success: 61 214 140; /* #3dd68c */
|
||||||
|
--status-danger: 255 155 153; /* #ff9b99 */
|
||||||
|
--status-warning: 255 202 22; /* #ffca16 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mode="light"][data-theme="sakura"] {
|
||||||
|
--surface-0: 242 230 232; /* #f2e6e8 */
|
||||||
|
--surface-1: 247 235 237; /* #f7ebed */
|
||||||
|
--surface-2: 251 240 242; /* #fbf0f2 */
|
||||||
|
--surface-3: 255 249 250; /* #fff9fa */
|
||||||
|
--surface-4: 255 252 253; /* #fffcfd */
|
||||||
|
--surface-5: 255 255 255; /* #ffffff */
|
||||||
|
--border: 224 210 213; /* #e0d2d5 */
|
||||||
|
--border-light: 237 224 227; /* #ede0e3 */
|
||||||
|
--accent: 167 64 95; /* #a7405f */
|
||||||
|
--accent-hover: 137 52 78; /* #89344e */
|
||||||
|
--accent-solid: 195 75 110; /* #c34b6e */
|
||||||
|
--accent-solid-hover: 168 65 95; /* #a8415f */
|
||||||
|
--fg-primary: 75 42 53; /* #4b2a35 */
|
||||||
|
--fg-secondary: 107 80 89; /* #6b5059 */
|
||||||
|
--fg-muted: 120 97 105; /* #786169 */
|
||||||
|
--status-success: 23 105 45; /* #17692d */
|
||||||
|
--status-danger: 173 28 38; /* #ad1c26 */
|
||||||
|
--status-warning: 124 82 0; /* #7c5200 */
|
||||||
|
--shadow-card: 0 1px 2px rgb(75 42 53 / 0.06), 0 2px 6px rgb(75 42 53 / 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ describe("buildTerminalTheme", () => {
|
|||||||
expect(t.brightWhite).toBe("#edeef0");
|
expect(t.brightWhite).toBe("#edeef0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("takes ANSI blue from --ansi-blue when a theme's accent is not blue", () => {
|
||||||
|
const t = buildTerminalTheme((n) => ({ ...vars, "--ansi-blue": "112 184 255" })[n] ?? "");
|
||||||
|
expect(t.blue).toBe("#70b8ff");
|
||||||
|
});
|
||||||
|
|
||||||
it("makes each bright twin lighter than its base colour", () => {
|
it("makes each bright twin lighter than its base colour", () => {
|
||||||
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
||||||
const sum = (hex: string) =>
|
const sum = (hex: string) =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
|
import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports";
|
||||||
|
|
||||||
const ROW = 20;
|
const ROW = 20;
|
||||||
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
|
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
|
||||||
@@ -15,16 +15,17 @@ describe("wheelReports", () => {
|
|||||||
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
|
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
|
||||||
const s: WheelState = { partial: 0 };
|
const s: WheelState = { partial: 0 };
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
// 30 steps of 10px = 300px = 15 rows.
|
// 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
|
||||||
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
|
for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
|
||||||
expect(sent).toBe(15 / LINES_PER_REPORT);
|
expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / LINES_PER_REPORT));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the carried remainder when the swipe turns around", () => {
|
it("drops the carried remainder when the swipe turns around", () => {
|
||||||
const s: WheelState = { partial: 0 };
|
const s: WheelState = { partial: 0 };
|
||||||
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
|
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up
|
// 8 rows up = 5.6 scrolled: one report, the 2 rows down forgotten.
|
||||||
|
for (let i = 0; i < 8; i++) sent += wheelReports(s, px(-20), ROW, 40);
|
||||||
expect(sent).toBe(-1);
|
expect(sent).toBe(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,7 +33,16 @@ describe("wheelReports", () => {
|
|||||||
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
|
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
|
||||||
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
|
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
|
||||||
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
|
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
|
||||||
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8);
|
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(6); // 8 × 0.7
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slows a run of wheel notches to SCROLL_SPEED of a report each", () => {
|
||||||
|
const s: WheelState = { partial: 0 };
|
||||||
|
let sent = 0;
|
||||||
|
for (let i = 0; i < 10; i++) sent += wheelReports(s, px(100), ROW, 40);
|
||||||
|
expect(sent).toBe(10 * SCROLL_SPEED);
|
||||||
|
// Turning around starts from zero: the first notch up moves at once.
|
||||||
|
expect(wheelReports(s, px(-100), ROW, 40)).toBe(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
|
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
|
||||||
|
|||||||
+187
-1
@@ -940,6 +940,15 @@ export type PoolProfile = {
|
|||||||
subscriptionType: string | null;
|
subscriptionType: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
};
|
};
|
||||||
|
/** A user a workspace is shared with (`/api/lanes/:id/shares`) — names only. */
|
||||||
|
export type LaneShare = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
sharedAt: string;
|
||||||
|
/** Disabled since it was shared: kept on the list, granting nothing. */
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
|
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
|
||||||
export type ToolProfileIdentity = {
|
export type ToolProfileIdentity = {
|
||||||
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
|
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
|
||||||
@@ -1858,6 +1867,8 @@ export const api = {
|
|||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
/** Pinned sessions first (by pin time), before the limit. */
|
/** Pinned sessions first (by pin time), before the limit. */
|
||||||
pinnedFirst?: boolean;
|
pinnedFirst?: boolean;
|
||||||
|
/** Server mode: the caller's OWN sessions, even for an admin/analyst (`mine=1`). */
|
||||||
|
mine?: boolean;
|
||||||
/** Only active sessions waiting on the user (`awaiting=1`). */
|
/** Only active sessions waiting on the user (`awaiting=1`). */
|
||||||
awaiting?: boolean;
|
awaiting?: boolean;
|
||||||
/** ISO bounds on `started_at`. */
|
/** ISO bounds on `started_at`. */
|
||||||
@@ -1878,6 +1889,7 @@ export const api = {
|
|||||||
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||||
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
||||||
if (params?.awaiting) qs.set("awaiting", "1");
|
if (params?.awaiting) qs.set("awaiting", "1");
|
||||||
|
if (params?.mine) qs.set("mine", "1");
|
||||||
if (params?.from) qs.set("from", params.from);
|
if (params?.from) qs.set("from", params.from);
|
||||||
if (params?.to) qs.set("to", params.to);
|
if (params?.to) qs.set("to", params.to);
|
||||||
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
||||||
@@ -3598,7 +3610,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);
|
||||||
@@ -3608,6 +3624,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",
|
||||||
@@ -3694,6 +3721,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. */
|
||||||
@@ -3779,6 +3808,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). */
|
||||||
@@ -3835,6 +3872,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"),
|
||||||
@@ -5018,6 +5104,31 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
||||||
* uploaded file's AgentHub copy is deleted. */
|
* uploaded file's AgentHub copy is deleted. */
|
||||||
|
/** GET /api/lanes/:id/shares — the users a workspace is shared with (server mode). */
|
||||||
|
shares: (id: number) =>
|
||||||
|
request<{ shares: LaneShare[]; canManage: boolean }>(`/workspaces/${id}/shares`),
|
||||||
|
/** PUT /api/lanes/:id/shares — the WHOLE list (owner or admin; `ENOTOWNER` else). */
|
||||||
|
setShares: (id: number, userIds: number[]) =>
|
||||||
|
request<{ shares: LaneShare[] }>(`/workspaces/${id}/shares`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ userIds }),
|
||||||
|
}),
|
||||||
|
/** GET /api/lanes/:id/presence — who has which pane open (server mode). */
|
||||||
|
presence: (id: number) =>
|
||||||
|
request<{ panes: Record<string, { id: number; name: string }[]> }>(
|
||||||
|
`/workspaces/${id}/presence`
|
||||||
|
),
|
||||||
|
/** GET /api/lanes/:id/sessions/:sid/shares — the users one session is shared with. */
|
||||||
|
sessionShares: (id: number, sid: string) =>
|
||||||
|
request<{ shares: LaneShare[]; canManage: boolean }>(
|
||||||
|
`/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`
|
||||||
|
),
|
||||||
|
/** PUT /api/lanes/:id/sessions/:sid/shares — the WHOLE list (owner or admin). */
|
||||||
|
setSessionShares: (id: number, sid: string, userIds: number[]) =>
|
||||||
|
request<{ shares: LaneShare[] }>(
|
||||||
|
`/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ userIds }) }
|
||||||
|
),
|
||||||
removeSource: (id: number, sourceId: number) =>
|
removeSource: (id: number, sourceId: number) =>
|
||||||
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
||||||
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
|
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
|
||||||
@@ -5211,6 +5322,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;
|
||||||
@@ -5271,6 +5386,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;
|
||||||
@@ -5284,6 +5406,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5596,6 +5720,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;
|
||||||
@@ -6403,6 +6530,9 @@ export interface RunHandle {
|
|||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
/** Initial prompt preview (first 500 chars), null when not provided. */
|
/** Initial prompt preview (first 500 chars), null when not provided. */
|
||||||
promptPreview: string | null;
|
promptPreview: string | null;
|
||||||
|
/** Server mode: a user the workspace is shared with opened it — they pay
|
||||||
|
* for it. Null/absent = the workspace's owner. */
|
||||||
|
startedBy?: { id: number; name: string } | null;
|
||||||
tool: LaneTool;
|
tool: LaneTool;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6787,3 +6917,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,79 @@
|
|||||||
|
/**
|
||||||
|
* @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. The status bar's Usage panel reads the same list
|
||||||
|
* (`HIDDEN_PROFILES_EVENT` tells it of a change in this tab), and when the
|
||||||
|
* live login is hidden both show the first visible profile in its place
|
||||||
|
* (`standIn`).
|
||||||
|
*
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
|
||||||
|
/** Fired on `window` after this tab changes the list. */
|
||||||
|
export const HIDDEN_PROFILES_EVENT = "agenthub:hidden-profiles";
|
||||||
|
|
||||||
|
/** 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 */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new Event(HIDDEN_PROFILES_EVENT));
|
||||||
|
} catch {
|
||||||
|
/* no window (tests outside jsdom) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `key` names a change to the hidden list (a `storage` event's key). */
|
||||||
|
export const isHiddenProfilesKey = (key: string | null) => key === STORAGE_KEY || key === null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry shown in place of a hidden live login: the first one in `list`
|
||||||
|
* that is not hidden, or null when the live one is visible (or none is left).
|
||||||
|
*/
|
||||||
|
export function standIn<T extends { accountUuid?: string | null; name?: string }>(
|
||||||
|
list: T[],
|
||||||
|
isActive: (x: T) => boolean,
|
||||||
|
hidden: string[]
|
||||||
|
): T | null {
|
||||||
|
const live = list.find(isActive);
|
||||||
|
if (
|
||||||
|
!live ||
|
||||||
|
!hidden.includes(profileKey({ accountUuid: live.accountUuid ?? null, name: live.name }))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
list.find(
|
||||||
|
(x) =>
|
||||||
|
!isActive(x) &&
|
||||||
|
!hidden.includes(profileKey({ accountUuid: x.accountUuid ?? null, name: x.name }))
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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];
|
||||||
|
}
|
||||||
@@ -324,17 +324,13 @@ export const SETTINGS_INDEX: {
|
|||||||
"codex",
|
"codex",
|
||||||
"omp",
|
"omp",
|
||||||
"pool",
|
"pool",
|
||||||
|
"pool account",
|
||||||
|
"automatic",
|
||||||
"hồ sơ",
|
"hồ sơ",
|
||||||
"tài khoản",
|
"tài khoản",
|
||||||
],
|
],
|
||||||
to: "#tool-profiles",
|
to: "#tool-profiles",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
section: "agents",
|
|
||||||
titleKey: "poolAccount.title",
|
|
||||||
keywords: ["pool", "account", "switch", "quota", "tài khoản", "pool account"],
|
|
||||||
to: "#pool-account",
|
|
||||||
},
|
|
||||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||||
{ section: "agents/codex", titleKey: "codexHome.title" },
|
{ section: "agents/codex", titleKey: "codexHome.title" },
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ function lighten(hex: string | undefined, amount: number): string | undefined {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
|
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
|
||||||
* `--status-*`, blue from `--accent`, black/white from the surface/fg ramp.
|
* `--status-*`, blue from `--ansi-blue` when a theme sets one (its accent is
|
||||||
|
* not blue: Terminal's green, Sunset Cloud's coral) else `--accent`,
|
||||||
|
* black/white from the surface/fg ramp.
|
||||||
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
|
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
|
||||||
* theme (darcula's keyword is orange), and a program printing "magenta" should
|
* theme (darcula's keyword is orange), and a program printing "magenta" should
|
||||||
* get magenta.
|
* get magenta.
|
||||||
@@ -50,7 +52,7 @@ export function buildTerminalTheme(read: VarReader): ITheme {
|
|||||||
const red = v("--status-danger");
|
const red = v("--status-danger");
|
||||||
const green = v("--status-success");
|
const green = v("--status-success");
|
||||||
const yellow = v("--status-warning");
|
const yellow = v("--status-warning");
|
||||||
const blue = v("--accent");
|
const blue = v("--ansi-blue") ?? v("--accent");
|
||||||
const magenta = "#c678dd";
|
const magenta = "#c678dd";
|
||||||
const cyan = "#56b6c2";
|
const cyan = "#56b6c2";
|
||||||
return {
|
return {
|
||||||
|
|||||||
+16
-1
@@ -1240,6 +1240,8 @@ export interface ToolQuota {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
/** The saved profile's label, when the account has one. */
|
/** The saved profile's label, when the account has one. */
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
|
/** Claude: the Anthropic account the row is about (hides a profile hidden in Settings). */
|
||||||
|
accountUuid?: string | null;
|
||||||
/** The signed-in account's email, when the tool's login names one. */
|
/** The signed-in account's email, when the tool's login names one. */
|
||||||
account?: string | null;
|
account?: string | null;
|
||||||
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
|
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
|
||||||
@@ -1573,7 +1575,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
|
||||||
@@ -1937,6 +1942,10 @@ export interface WSMessage {
|
|||||||
| "remote_source.status"
|
| "remote_source.status"
|
||||||
| "remote_data.updated"
|
| "remote_data.updated"
|
||||||
| "lane_update"
|
| "lane_update"
|
||||||
|
/** A workspace was shared with / taken back from the users named (`userIds`). */
|
||||||
|
| "lane_access"
|
||||||
|
/** Who has a pane open changed: `{laneId, runId, users}` (pane-presence.js). */
|
||||||
|
| "pane_presence"
|
||||||
/** One output line from a lane's profile hook, while it runs. */
|
/** One output line from a lane's profile hook, while it runs. */
|
||||||
| "lane_hook_output"
|
| "lane_hook_output"
|
||||||
/** A lane's stack finished coming up (or failed to), with fresh facts. */
|
/** A lane's stack finished coming up (or failed to), with fresh facts. */
|
||||||
@@ -1971,6 +1980,10 @@ export interface WSMessage {
|
|||||||
/** 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;
|
||||||
|
/** Server mode, an admin/analyst's session/agent/event frames only: whether
|
||||||
|
* the session is their OWN (they also get every other user's). Absent for a
|
||||||
|
* developer (who only ever gets their own) and in local mode. */
|
||||||
|
mine?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── Session stats ─────
|
// ───── Session stats ─────
|
||||||
@@ -2697,6 +2710,8 @@ export interface Lane {
|
|||||||
owner_display_name?: string | null;
|
owner_display_name?: string | null;
|
||||||
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
|
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
|
||||||
owner_avatar_at?: string | null;
|
owner_avatar_at?: string | null;
|
||||||
|
/** Server mode: `"session"` when only sessions of it are shared with this viewer. */
|
||||||
|
access?: "session";
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
kind: "managed" | "adopted";
|
kind: "managed" | "adopted";
|
||||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -8,13 +8,19 @@
|
|||||||
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
|
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
|
||||||
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
|
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
|
||||||
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
|
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
|
||||||
* finger, and a wheel notch always sends at least one.
|
* finger, and a wheel notch always sends at least one. `SCROLL_SPEED` then
|
||||||
|
* slows both down (the user asked for 30 % less): a touchpad's travel is
|
||||||
|
* scaled, and a notch's reports are carried as a fraction, rounded, so the
|
||||||
|
* first notch still moves and the average comes out at that share.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
|
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
|
||||||
export const LINES_PER_REPORT = 5;
|
export const LINES_PER_REPORT = 5;
|
||||||
|
|
||||||
|
/** Share of the travel a pane scrolls (0.7 = 30 % slower than 1:1). */
|
||||||
|
export const SCROLL_SPEED = 0.7;
|
||||||
|
|
||||||
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
|
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
|
||||||
const NOTCH_PX = 50;
|
const NOTCH_PX = 50;
|
||||||
|
|
||||||
@@ -25,6 +31,10 @@ const DOM_DELTA_PAGE = 2;
|
|||||||
export interface WheelState {
|
export interface WheelState {
|
||||||
/** Touchpad travel not yet sent, in rows (signed). */
|
/** Touchpad travel not yet sent, in rows (signed). */
|
||||||
partial: number;
|
partial: number;
|
||||||
|
/** Wheel-notch reports owed but not sent yet (signed, a fraction). */
|
||||||
|
notch?: number;
|
||||||
|
/** Direction of the last notch (the carry above may round to either sign). */
|
||||||
|
notchDir?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,18 +52,25 @@ export function wheelReports(
|
|||||||
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
|
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
|
||||||
// Touchpad: carry the remainder, so slow swipes still add up.
|
// Touchpad: carry the remainder, so slow swipes still add up.
|
||||||
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
|
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
|
||||||
state.partial += ev.deltaY / rowPx;
|
state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED;
|
||||||
const n = Math.trunc(state.partial / LINES_PER_REPORT);
|
const n = Math.trunc(state.partial / LINES_PER_REPORT);
|
||||||
state.partial -= n * LINES_PER_REPORT;
|
state.partial -= n * LINES_PER_REPORT;
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
// A notch (or a fast flick): at least one report, more for a long one.
|
// A notch (or a fast flick): at least one report, more for a long one.
|
||||||
state.partial = 0;
|
state.partial = 0;
|
||||||
|
if (state.notchDir !== sign) state.notch = 0; // turned around
|
||||||
|
state.notchDir = sign;
|
||||||
const lines =
|
const lines =
|
||||||
ev.deltaMode === DOM_DELTA_LINE
|
ev.deltaMode === DOM_DELTA_LINE
|
||||||
? ev.deltaY
|
? ev.deltaY
|
||||||
: ev.deltaMode === DOM_DELTA_PAGE
|
: ev.deltaMode === DOM_DELTA_PAGE
|
||||||
? ev.deltaY * rows
|
? ev.deltaY * rows
|
||||||
: ev.deltaY / rowPx;
|
: ev.deltaY / rowPx;
|
||||||
return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT));
|
const owed =
|
||||||
|
(state.notch ?? 0) +
|
||||||
|
sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)) * SCROLL_SPEED;
|
||||||
|
const n = sign * Math.round(Math.abs(owed));
|
||||||
|
state.notch = owed - n;
|
||||||
|
return n;
|
||||||
}
|
}
|
||||||
|
|||||||
+607
-25
@@ -53,19 +53,23 @@
|
|||||||
* @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,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +87,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 +101,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 +127,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 +186,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 +308,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 +326,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 +546,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 +561,20 @@ export function Artifacts() {
|
|||||||
onUninstall: () => void uninstall(a),
|
onUninstall: () => void uninstall(a),
|
||||||
onPreview: (path) => {
|
onPreview: (path) => {
|
||||||
setDetailId(null);
|
setDetailId(null);
|
||||||
setPreview({ item: a, path });
|
if (path || !capabilities(a).mayCopy) {
|
||||||
|
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 +677,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 +751,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 +842,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 +934,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 +1237,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 +1299,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 +1373,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 +1452,7 @@ function ArtifactMenu({
|
|||||||
menu,
|
menu,
|
||||||
onOpen,
|
onOpen,
|
||||||
onShare,
|
onShare,
|
||||||
|
onMove,
|
||||||
onReshare,
|
onReshare,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -1369,12 +1506,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 +1557,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 +1612,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 +1707,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 +1844,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 +2256,422 @@ 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 h-7 items-center gap-1.5 rounded-full border px-3 text-xs whitespace-nowrap transition-colors ${
|
||||||
|
on
|
||||||
|
? "border-accent/40 bg-accent-muted text-fg-primary"
|
||||||
|
: "border-border text-fg-secondary hover:border-border-light hover:bg-surface-2 hover:text-fg-primary"
|
||||||
|
}`;
|
||||||
|
const count = (n: number, on: boolean) => (
|
||||||
|
<span
|
||||||
|
className={`min-w-[1.25rem] rounded-full px-1.5 text-center text-[10px] leading-4 tabular-nums ${
|
||||||
|
on ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const iconBtn =
|
||||||
|
"inline-flex h-7 w-7 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5" data-testid="artifact-folders">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={!active}
|
||||||
|
className={chip(!active) + lit("")}
|
||||||
|
onClick={() => onPick("")}
|
||||||
|
title={folders.length ? t("folders.dropOutHint") : undefined}
|
||||||
|
{...dropProps("")}
|
||||||
|
>
|
||||||
|
<Folder className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{t("folders.all")}
|
||||||
|
</button>
|
||||||
|
{folders.map((f) => {
|
||||||
|
const on = active === f.id;
|
||||||
|
return editing === f.id ? null : (
|
||||||
|
<span key={f.id} className="inline-flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
|
className={chip(on) + lit(f.id)}
|
||||||
|
onClick={() => onPick(f.id)}
|
||||||
|
onDoubleClick={() => begin(f.id, f.name)}
|
||||||
|
title={t("folders.dropHint")}
|
||||||
|
data-testid={`artifact-folder-${f.id}`}
|
||||||
|
{...dropProps(f.id)}
|
||||||
|
>
|
||||||
|
<span className="max-w-[12rem] truncate">{f.name}</span>
|
||||||
|
{count(counts(f.id), on)}
|
||||||
|
</button>
|
||||||
|
{on && !editing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${iconBtn} ml-0.5`}
|
||||||
|
onClick={() => begin(f.id, f.name)}
|
||||||
|
title={t("folders.rename")}
|
||||||
|
aria-label={t("folders.rename")}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${iconBtn} hover:!text-status-danger`}
|
||||||
|
onClick={() => void remove(f)}
|
||||||
|
title={t("folders.delete")}
|
||||||
|
aria-label={t("folders.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
className="input h-7 w-44 rounded-full px-3 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="inline-flex h-7 items-center gap-1 rounded-full border border-dashed border-border px-3 text-xs text-fg-muted transition-colors hover:border-accent/50 hover:text-fg-primary"
|
||||||
|
onClick={() => begin("new")}
|
||||||
|
data-testid="artifact-folder-new"
|
||||||
|
>
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
{t("folders.new")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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 +2852,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,9 @@ 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 { 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";
|
||||||
@@ -253,7 +254,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
};
|
};
|
||||||
}, [location.key, location.hash]);
|
}, [location.key, location.hash]);
|
||||||
|
|
||||||
const { isAdmin, local, seesAll } = usePermissions();
|
const { isAdmin, local } = usePermissions();
|
||||||
const authState = useAuth().state;
|
const authState = useAuth().state;
|
||||||
const signedIn = authState.mode === "server" && !!authState.user;
|
const signedIn = authState.mode === "server" && !!authState.user;
|
||||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||||
@@ -308,15 +309,18 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
<AgentInstructionsSettings />
|
<AgentInstructionsSettings />
|
||||||
<SessionDefaultsSettings />
|
<SessionDefaultsSettings />
|
||||||
<AiToolsSettings settingsHref={toolHref} />
|
<AiToolsSettings settingsHref={toolHref} />
|
||||||
{/* Every AI tool's login profiles, linked to the account pool: the
|
{/* Every AI tool's login profiles with the pool accounts the user may
|
||||||
host's logins, a staff-only read (a developer would get 403s). */}
|
run on as rows of the same list: staff see the host's logins too,
|
||||||
{seesAll && <ToolProfilesSettings />}
|
a developer only the pool rows (it hides itself with none). */}
|
||||||
{/* Every user's own pick of a pool account (server mode; hides itself). */}
|
<ToolProfilesSettings />
|
||||||
<PoolAccountSettings />
|
|
||||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||||
<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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1002,8 +1002,11 @@ export function Workspace() {
|
|||||||
const showRoot = !inner || inner.root || repos.length === 0;
|
const showRoot = !inner || inner.root || repos.length === 0;
|
||||||
// A file the chat asks for lies in the inner repo whose path prefixes it.
|
// A file the chat asks for lies in the inner repo whose path prefixes it.
|
||||||
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
|
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
|
||||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
// The stacked sections are an accordion: ONE is open at a time ("" = the
|
||||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
// workspace folder, `repo:<path>` an inner repo, else the folder's id).
|
||||||
|
// `undefined` = the first section (also when the open one is gone); `null`
|
||||||
|
// = every section folded.
|
||||||
|
const [openSource, setOpenSource] = useState<string | null | undefined>(undefined);
|
||||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||||
// workspace (the folder stays on disk). The workspace folder never closes.
|
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||||
const sourceCtx = useContextMenu<string>();
|
const sourceCtx = useContextMenu<string>();
|
||||||
@@ -1062,11 +1065,7 @@ export function Workspace() {
|
|||||||
// Its path is the workspace folder's: unfold that section (or the
|
// Its path is the workspace folder's: unfold that section (or the
|
||||||
// inner repo's that holds it).
|
// inner repo's that holds it).
|
||||||
const repo = repoOfRef.current(path);
|
const repo = repoOfRef.current(path);
|
||||||
setFoldedSources((prev) => {
|
setOpenSource(repo ? `repo:${repo.path}` : "");
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(repo ? `repo:${repo.path}` : "");
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setChangesOpen(true);
|
setChangesOpen(true);
|
||||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||||
};
|
};
|
||||||
@@ -1270,8 +1269,12 @@ export function Workspace() {
|
|||||||
// editor: no page padding, no cards, dividers between its regions.
|
// editor: no page padding, no cards, dividers between its regions.
|
||||||
const sessionView = !!currentLane && !home;
|
const sessionView = !!currentLane && !home;
|
||||||
previewHostRef.current = sessionView;
|
previewHostRef.current = sessionView;
|
||||||
|
// The home is locked to the viewport too: the Changes panel sits beside it
|
||||||
|
// at full height, and the home scrolls inside its own column.
|
||||||
const viewportLocked =
|
const viewportLocked =
|
||||||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
sessionView ||
|
||||||
|
(home && !!currentLane) ||
|
||||||
|
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||||
|
|
||||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||||
setLaneActionError(null);
|
setLaneActionError(null);
|
||||||
@@ -1345,27 +1348,41 @@ export function Workspace() {
|
|||||||
it rather than under it. It collapses to a rail instead of
|
it rather than under it. It collapses to a rail instead of
|
||||||
unmounting nothing — there is no live subscription inside it, so
|
unmounting nothing — there is no live subscription inside it, so
|
||||||
unmounting is safe and keeps a closed panel from polling. */}
|
unmounting is safe and keeps a closed panel from polling. */}
|
||||||
{home && currentLane && (
|
<div ref={changesRowRef} className="relative flex min-h-0 flex-1">
|
||||||
<WorkspaceHome
|
{/* The home shares the row with the Changes panel, like the session
|
||||||
lane={currentLane}
|
view's console does — so a workspace's changes show before any
|
||||||
onRenamed={() => void refreshLanes()}
|
session is open. */}
|
||||||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
{home && currentLane && (
|
||||||
sourcesVersion={sourcesVersion}
|
<div
|
||||||
onMenuAction={(action) => {
|
data-testid="workspace-home-column"
|
||||||
if (action === "new-here") {
|
// `relative`: the home's absolutely placed bits (a row's sr-only
|
||||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
// label) must be clipped by THIS scroller — placed against the
|
||||||
setAddLaneOpen(true);
|
// row outside it they stretched the page into an empty scroll.
|
||||||
} else if (action === "reset" || action === "remove") {
|
// No visible scrollbar: the wheel and touch still scroll it.
|
||||||
setDestructive({ lane: currentLane, action });
|
className={`no-scrollbar relative min-h-0 min-w-0 flex-1 overflow-y-auto${
|
||||||
}
|
changesOpen && changesFull ? " hidden" : ""
|
||||||
}}
|
}${changesOpen && !changesFull ? " pr-5" : ""}`}
|
||||||
/>
|
>
|
||||||
)}
|
<WorkspaceHome
|
||||||
<div ref={changesRowRef} className={`relative flex min-h-0 flex-1${home ? " hidden" : ""}`}>
|
lane={currentLane}
|
||||||
|
onRenamed={() => void refreshLanes()}
|
||||||
|
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||||
|
sourcesVersion={sourcesVersion}
|
||||||
|
onMenuAction={(action) => {
|
||||||
|
if (action === "new-here") {
|
||||||
|
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||||
|
setAddLaneOpen(true);
|
||||||
|
} else if (action === "reset" || action === "remove") {
|
||||||
|
setDestructive({ lane: currentLane, action });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<section
|
<section
|
||||||
data-testid="lane-detail"
|
data-testid="lane-detail"
|
||||||
className={`flex min-h-0 min-w-0 flex-1 flex-col${
|
className={`flex min-h-0 min-w-0 flex-1 flex-col${
|
||||||
currentLane && changesOpen && changesFull ? " hidden" : ""
|
home || (currentLane && changesOpen && changesFull) ? " hidden" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* No header row: the console's tab bar is the header. Only a
|
{/* No header row: the console's tab bar is the header. Only a
|
||||||
@@ -1792,10 +1809,17 @@ export function Workspace() {
|
|||||||
// One repo only: the one panel, as it always was.
|
// One repo only: the one panel, as it always was.
|
||||||
if (sections.length === 1) return panel;
|
if (sections.length === 1) return panel;
|
||||||
// Else every repo is a section, stacked like VS Code's
|
// Else every repo is a section, stacked like VS Code's
|
||||||
// Source Control; the open ones share the height. A
|
// Source Control — an accordion: opening one folds the
|
||||||
|
// others, so the open one has the whole height. A
|
||||||
// folded one stays mounted (CSS), so an unsaved edit in
|
// folded one stays mounted (CSS), so an unsaved edit in
|
||||||
// it survives.
|
// it survives.
|
||||||
const folded = foldedSources.has(key);
|
const openKey =
|
||||||
|
openSource === null
|
||||||
|
? null
|
||||||
|
: sections.some((x) => x.key === openSource)
|
||||||
|
? openSource
|
||||||
|
: sections[0]!.key;
|
||||||
|
const folded = key !== openKey;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
key={key}
|
key={key}
|
||||||
@@ -1811,13 +1835,7 @@ export function Workspace() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-expanded={!folded}
|
aria-expanded={!folded}
|
||||||
onContextMenu={(e) => sourceCtx.open(e, key)}
|
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||||||
onClick={() =>
|
onClick={() => setOpenSource(folded ? key : null)}
|
||||||
setFoldedSources((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (!next.delete(key)) next.add(key);
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
{folded ? (
|
{folded ? (
|
||||||
@@ -1884,12 +1902,13 @@ export function Workspace() {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
{/* The workspace's context — the home's right column, compact. */}
|
{/* The workspace's context — the home's right column, compact
|
||||||
|
(not on the home itself, which shows that column). */}
|
||||||
<section
|
<section
|
||||||
data-testid="panel-workspace-context"
|
data-testid="panel-workspace-context"
|
||||||
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
|
className={`-mx-3 shrink-0 flex-col border-t border-border px-3 ${
|
||||||
contextOpen ? "max-h-[45%]" : ""
|
home ? "hidden" : "flex"
|
||||||
}`}
|
} ${contextOpen ? "max-h-[45%]" : ""}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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: [] })),
|
||||||
|
|||||||
@@ -196,17 +196,20 @@ describe("Workspace — the Changes panel's width", () => {
|
|||||||
expect(size()).toHaveAttribute("aria-label", "Full screen");
|
expect(size()).toHaveAttribute("aria-label", "Full screen");
|
||||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||||
|
|
||||||
// Full width HIDES the console beside it — hidden, still mounted.
|
// The workspace's home sits beside the panel (no session is open), and
|
||||||
|
// full width HIDES it — hidden, still mounted; same for a console.
|
||||||
|
const beside = () => screen.getByTestId("workspace-home-column");
|
||||||
|
expect(beside().className).not.toContain("hidden");
|
||||||
fireEvent.click(size());
|
fireEvent.click(size());
|
||||||
expect(card().className).toContain("w-full");
|
expect(card().className).toContain("w-full");
|
||||||
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
expect(beside().className).toContain("hidden");
|
||||||
expect(size()).toHaveAttribute("aria-label", "Restore size");
|
expect(size()).toHaveAttribute("aria-label", "Restore size");
|
||||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||||
|
|
||||||
// Restore brings the console back beside it.
|
// Restore brings the console back beside it.
|
||||||
fireEvent.click(size());
|
fireEvent.click(size());
|
||||||
expect(card().className).toContain("w-[25%]");
|
expect(card().className).toContain("w-[25%]");
|
||||||
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
|
expect(beside().className).not.toContain("hidden");
|
||||||
|
|
||||||
// Collapsing from full screen reopens at 25/75, not back at full width.
|
// Collapsing from full screen reopens at 25/75, not back at full width.
|
||||||
fireEvent.click(size());
|
fireEvent.click(size());
|
||||||
@@ -288,11 +291,19 @@ describe("Workspace — the Changes panel's width", () => {
|
|||||||
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
|
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
|
||||||
expect(api.lanes.git).toHaveBeenCalledWith(1);
|
expect(api.lanes.git).toHaveBeenCalledWith(1);
|
||||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||||
// A folded section keeps its panel mounted, only hidden.
|
// An accordion: the first section starts open, the others folded.
|
||||||
const toggle = sections[0]!.querySelector("button")!;
|
const toggle = sections[0]!.querySelector("button")!;
|
||||||
fireEvent.click(toggle);
|
const shop = sections[1]!.querySelector("button")!;
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(shop).toHaveAttribute("aria-expanded", "false");
|
||||||
|
// Opening one folds the other; a folded section keeps its panel mounted.
|
||||||
|
fireEvent.click(shop);
|
||||||
|
expect(shop).toHaveAttribute("aria-expanded", "true");
|
||||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
||||||
|
// Clicking the open one folds it too.
|
||||||
|
fireEvent.click(shop);
|
||||||
|
expect(shop).toHaveAttribute("aria-expanded", "false");
|
||||||
// A chat's file is the workspace folder's: its section unfolds.
|
// A chat's file is the workspace folder's: its section unfolds.
|
||||||
act(() => {
|
act(() => {
|
||||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||||
@@ -390,13 +401,13 @@ describe("Workspace — the Changes panel's width", () => {
|
|||||||
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
|
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
|
||||||
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
|
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
|
||||||
// A chat's file inside an inner repo opens in that repo's section.
|
// A chat's file inside an inner repo opens in that repo's section.
|
||||||
fireEvent.click(sections[1]!.querySelector("button")!);
|
|
||||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||||
act(() => {
|
act(() => {
|
||||||
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
|
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
|
||||||
});
|
});
|
||||||
await settle();
|
await settle();
|
||||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
|
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(sections[0]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(
|
||||||
{ lane: 2, repo: "apps/web" },
|
{ lane: 2, repo: "apps/web" },
|
||||||
"src/x.ts",
|
"src/x.ts",
|
||||||
|
|||||||
@@ -1203,7 +1203,19 @@ describe("Workspace — home and session view", () => {
|
|||||||
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
|
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
|
||||||
await renderWorkspace();
|
await renderWorkspace();
|
||||||
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
|
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
|
expect(screen.getByTestId("lane-detail")).toHaveClass("hidden");
|
||||||
|
// The Changes panel (or its rail) sits beside the home, before any session.
|
||||||
|
expect(screen.getByTestId("workspace-home-column").parentElement).not.toHaveClass("hidden");
|
||||||
|
// Its own scroller contains its absolutely placed bits (sr-only labels),
|
||||||
|
// or they stretch the page past the viewport.
|
||||||
|
expect(screen.getByTestId("workspace-home-column")).toHaveClass(
|
||||||
|
"relative",
|
||||||
|
"overflow-y-auto",
|
||||||
|
"no-scrollbar"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("lane-changes-card") ?? screen.queryByTestId("lane-changes-expand")
|
||||||
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
|
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
|
||||||
|
|||||||
@@ -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.63 (rebuild client to match)
|
UI build v2.8.78 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+77
-13
@@ -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
|
||||||
@@ -2094,6 +2098,58 @@ POST /api/locks/:name/release
|
|||||||
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
|
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
|
||||||
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
|
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
|
||||||
|
|
||||||
|
#### Sharing a workspace (server mode)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/lanes/:id/shares → { shares: [{id, username, name, sharedAt}], canManage }
|
||||||
|
PUT /api/lanes/:id/shares {"userIds": [2, 5]} → { shares }
|
||||||
|
```
|
||||||
|
|
||||||
|
The users a workspace is shared with (`lane_shares`, names only). `PUT` takes the
|
||||||
|
WHOLE list — at most 200 enabled users, never the owner (`400 EBADUSER`) — from the
|
||||||
|
owner or an admin only (`403 ENOTOWNER`), same-origin, each change audited. Local
|
||||||
|
mode: `GET` answers an empty list, `PUT` `400 ELOCALMODE`. A user it is shared with
|
||||||
|
sees and works in it like its owner, but `PATCH {title}`, `reset`/`remove`/`purge`,
|
||||||
|
`DELETE` and `PUT …/shares` answer them `403 ENOTOWNER`. A session they start or
|
||||||
|
resume (`POST /api/lanes/:id/start`, `POST /api/run`) runs on THEIR OWN account —
|
||||||
|
a pool setup-token (Claude) or API key (Codex, Cursor) they may use, or an AI
|
||||||
|
Proxy of theirs — else `409 ENOCOLLABACCOUNT`; resuming a non-Claude session the other side pays
|
||||||
|
for, or a collaborator's `resumeLast`, is `409 ECOLLABRESUME`; their spend quota (`429 EQUOTA`)
|
||||||
|
decides, not the owner's. Every pane they open (a Terminal tab too) records them
|
||||||
|
in `dashboard_runs.started_by`: its pane token acts as them, and an AI pane's
|
||||||
|
session hours are billed to them (`session_payers`; a Terminal tab's stay the
|
||||||
|
owner's). A user disabled since stays on the
|
||||||
|
list (`disabled: true`) and may be kept or removed; only a new one must be enabled. Users taken off
|
||||||
|
get the WebSocket message `lane_access` `{laneId, userIds}` (sent to them only). See
|
||||||
|
`docs/SERVER-MODE.md` → Sharing a workspace. A collaborator's pane token
|
||||||
|
answers only that workspace's reads, the workspace list (filtered to it), its
|
||||||
|
stage / up / down / hook / feature / mcp-sync writes and `/api/health`
|
||||||
|
(`403 EPANESCOPE` — never a start or resume).
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/lanes/:id/presence → { panes: { "<run id>": [{id, name}] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Who has which pane open (server mode; local mode `{panes: {}}`), in memory;
|
||||||
|
changes come as the WebSocket message `pane_presence` `{laneId, runId, users}`
|
||||||
|
to the workspace's owner and full collaborators. A run handle
|
||||||
|
(`GET /api/run`) carries `startedBy: {id, name}` when a collaborator opened
|
||||||
|
it — they pay for it. The first keystroke of a terminal attach into a shared
|
||||||
|
workspace's pane by anyone but its owner is audited (`type into <run id>`).
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/lanes/:id/sessions/:sid/shares → { shares, canManage }
|
||||||
|
PUT /api/lanes/:id/sessions/:sid/shares {"userIds": [3]} → { shares }
|
||||||
|
```
|
||||||
|
|
||||||
|
One session shared on its own (`session_shares`), same rules as the workspace
|
||||||
|
list (owner or admin, enabled users, audited `share session <id>`, `404
|
||||||
|
ENOSESSION` for a session of another workspace). The user gets the workspace
|
||||||
|
in `GET /api/lanes` with `access: "session"`, only that session in its session
|
||||||
|
lists, the pane holding it, a resume of it (`start {resumeSessionId}` — no
|
||||||
|
fork, no new session, no shell), and the workspace's git/Files reads and git
|
||||||
|
writes; any other call on the workspace answers `403 ESESSIONSHARE`.
|
||||||
|
|
||||||
#### Workspace context: instructions, folders, context files, memory
|
#### Workspace context: instructions, folders, context files, memory
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -2176,6 +2232,7 @@ Returns all sessions, ordered by most recent activity.
|
|||||||
| `offset` | integer | 0 | Pagination offset |
|
| `offset` | integer | 0 | Pagination offset |
|
||||||
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `status` value — use `awaiting=1` |
|
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `status` value — use `awaiting=1` |
|
||||||
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
|
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
|
||||||
|
| `mine` | `1` | - | Server mode: only the caller's OWN sessions (workspaces they own, or their Linux account's), even for an admin or analyst. The pet seeds its count from it. No-op in local mode |
|
||||||
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
|
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
|
||||||
| `cwd` | string | - | Exact working directory |
|
| `cwd` | string | - | Exact working directory |
|
||||||
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
|
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
|
||||||
@@ -2956,7 +3013,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
|
|||||||
GET /api/pricing/quota[?force=1]
|
GET /api/pricing/quota[?force=1]
|
||||||
```
|
```
|
||||||
|
|
||||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, accountUuid?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. Every Claude row carries its Anthropic `accountUuid` (null when unknown). **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
||||||
#### Analytics
|
#### Analytics
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -3490,7 +3547,7 @@ 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.
|
||||||
|
|
||||||
@@ -3511,8 +3568,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?}
|
||||||
@@ -3524,11 +3581,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
|
||||||
@@ -3551,7 +3610,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
|
||||||
|
|
||||||
@@ -3648,7 +3710,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
|
||||||
|
|
||||||
@@ -3718,6 +3780,8 @@ stateDiagram-v2
|
|||||||
|
|
||||||
Server broadcasts JSON messages to all connected clients:
|
Server broadcasts JSON messages to all connected clients:
|
||||||
|
|
||||||
|
Server mode: a developer gets only the frames of their own sessions and workspaces. An admin or analyst gets every user's, and each `session_created`, `session_updated`, `agent_created`, `agent_updated` and `new_event` frame they receive also carries `"mine": true|false`, which says whether the session is their own (the rule of `GET /api/sessions?mine=1`). The pet counts only frames with `mine` absent or true. Local mode never adds `mine`.
|
||||||
|
|
||||||
#### session.created
|
#### session.created
|
||||||
|
|
||||||
Sent when a new session is created.
|
Sent when a new session is created.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user