Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59533b997b | |||
| e21120d51d | |||
| c946b73d11 | |||
| 3daf7ed4cb | |||
| f9869ada6d | |||
| 0fc436dc0c | |||
| cd25791160 | |||
| 2a83551eee | |||
| d0889d5250 | |||
| 6958a7b4f6 | |||
| aa7f1b1c60 | |||
| 70d7cf30c2 | |||
| 5912e9e613 | |||
| 497faae3bf | |||
| e6ce8009b1 | |||
| 705353b0da | |||
| 0792c88612 | |||
| a3d1dce2ca | |||
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda | |||
| 1dd2521183 | |||
| 21a042dc2b | |||
| 85edc88a6d | |||
| c229b4b79b | |||
| 9721941962 | |||
| b777e08371 | |||
| d0b4f8aaf6 | |||
| 916505fbac | |||
| 75a4693f27 | |||
| cd6a1d36ff | |||
| 2342f54179 | |||
| 246b554dab | |||
| 7ee79292fa | |||
| 600e8eec6a | |||
| 3ec1978176 | |||
| 12ea853170 | |||
| 7e41c81d53 | |||
| 2d6c751fb2 | |||
| e0b8d5641e | |||
| faa96d1964 | |||
| 68c3cfaf26 | |||
| 1ad2fec073 | |||
| 1294ce1faa | |||
| 5d62003fe5 | |||
| 12d53afaeb | |||
| 4263b3eb26 | |||
| d58f13e1ce | |||
| 81b3cbda65 | |||
| 0a99eed369 | |||
| 1ec9f2e592 | |||
| a401a318c5 | |||
| 5caad0df2d | |||
| 01e66ba122 | |||
| 923c1635b4 | |||
| c37bc5205b |
@@ -39,6 +39,16 @@
|
|||||||
"async": true
|
"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>
|
||||||
|
|||||||
@@ -287,6 +287,11 @@ function UploadChip({ job }: { job: UploadJob }) {
|
|||||||
{isArmed("stop") && !stopping && (
|
{isArmed("stop") && !stopping && (
|
||||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||||
)}
|
)}
|
||||||
|
{job.waiting && !stopping && (
|
||||||
|
<p className="text-[11px] text-status-warning" role="status" data-testid="upload-waiting">
|
||||||
|
{t("uploads.waiting")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||||
{job.folder}
|
{job.folder}
|
||||||
{job.name ? ` · ${job.name}` : ""}
|
{job.name ? ` · ${job.name}` : ""}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { CodeBlock } from "./CodeBlock";
|
import { CodeBlock } from "./CodeBlock";
|
||||||
|
import { onLinkClick } from "../../lib/openInPreview";
|
||||||
|
|
||||||
type Block =
|
type Block =
|
||||||
| { kind: "code"; lang: string; code: string }
|
| { kind: "code"; lang: string; code: string }
|
||||||
@@ -305,7 +306,13 @@ const LABEL_CLASS = (word: string) =>
|
|||||||
|
|
||||||
function UrlLink({ url }: { url: string }) {
|
function UrlLink({ url }: { url: string }) {
|
||||||
return (
|
return (
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => onLinkClick(e, url)}
|
||||||
|
className={LINK_CLASS}
|
||||||
|
>
|
||||||
{url}
|
{url}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -466,6 +473,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => onLinkClick(e, href)}
|
||||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||||
>
|
>
|
||||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||||
|
|||||||
@@ -865,7 +865,7 @@ function MessageListView({
|
|||||||
className={
|
className={
|
||||||
compact
|
compact
|
||||||
? "space-y-3 py-1 pr-2"
|
? "space-y-3 py-1 pr-2"
|
||||||
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
|
: "mx-auto flex w-full max-w-5xl flex-col gap-3 px-4 py-6"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import {
|
|||||||
type FsPlace,
|
type FsPlace,
|
||||||
type GitSource,
|
type GitSource,
|
||||||
isHomePlace,
|
isHomePlace,
|
||||||
|
newUploadId,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
@@ -529,7 +530,13 @@ export function FileBrowser({
|
|||||||
await fn();
|
await fn();
|
||||||
if (done) toast.success(done);
|
if (done) toast.success(done);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
|
toast.error(
|
||||||
|
errCode(e) === "EUPGRADE"
|
||||||
|
? t("fx.needsUpgrade")
|
||||||
|
: errCode(e) === "ESECRET"
|
||||||
|
? t("fx.secret")
|
||||||
|
: errText(e)
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
changed();
|
changed();
|
||||||
@@ -682,13 +689,21 @@ export function FileBrowser({
|
|||||||
// false once the server says a batch cannot be written here (an old helper).
|
// false once the server says a batch cannot be written here (an old helper).
|
||||||
let batchable = true;
|
let batchable = true;
|
||||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||||
|
const onWait = (waiting: boolean) => job.update({ waiting });
|
||||||
/** One file; false = the user cancelled. */
|
/** One file; false = the user cancelled. */
|
||||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||||
if (signal.aborted) return false;
|
if (signal.aborted) return false;
|
||||||
const dest = join(dir, rel);
|
const dest = join(dir, rel);
|
||||||
show(rel);
|
show(rel);
|
||||||
|
// One id for every attempt: a retry with another `conflict` reuses the staged bytes.
|
||||||
|
const upload = newUploadId();
|
||||||
const send = (c: FsConflict) =>
|
const send = (c: FsConflict) =>
|
||||||
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
|
api.files.uploadStaged(place, dest, file, c, {
|
||||||
|
upload,
|
||||||
|
onProgress: (n) => show(rel, n),
|
||||||
|
onWait,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||||
if (errCode(e) !== "EEXISTS") throw e;
|
if (errCode(e) !== "EEXISTS") throw e;
|
||||||
@@ -701,6 +716,9 @@ export function FileBrowser({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||||
|
} finally {
|
||||||
|
onWait(false);
|
||||||
|
void api.files.dropUpload(place, upload);
|
||||||
}
|
}
|
||||||
sent += file.size;
|
sent += file.size;
|
||||||
done++;
|
done++;
|
||||||
@@ -720,6 +738,7 @@ export function FileBrowser({
|
|||||||
return a.choice;
|
return a.choice;
|
||||||
};
|
};
|
||||||
if (signal.aborted) return "cancel";
|
if (signal.aborted) return "cancel";
|
||||||
|
const upload = newUploadId();
|
||||||
try {
|
try {
|
||||||
const c = await api.files.uploadCheck(
|
const c = await api.files.uploadCheck(
|
||||||
place,
|
place,
|
||||||
@@ -733,7 +752,12 @@ export function FileBrowser({
|
|||||||
let policy = await choose(c.taken);
|
let policy = await choose(c.taken);
|
||||||
if (!policy) return "cancel";
|
if (!policy) return "cancel";
|
||||||
const send = (p: FsConflict) =>
|
const send = (p: FsConflict) =>
|
||||||
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
|
api.files.uploadBatchStaged(place, dir, b, p, {
|
||||||
|
upload,
|
||||||
|
onProgress: (n) => show(label, n),
|
||||||
|
onWait,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
const r = await send(policy).catch(async (e) => {
|
const r = await send(policy).catch(async (e) => {
|
||||||
// Taken meanwhile: ask about those, then once more.
|
// Taken meanwhile: ask about those, then once more.
|
||||||
if (errCode(e) !== "EEXISTS") throw e;
|
if (errCode(e) !== "EEXISTS") throw e;
|
||||||
@@ -752,6 +776,9 @@ export function FileBrowser({
|
|||||||
return "fallback";
|
return "fallback";
|
||||||
}
|
}
|
||||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||||
|
} finally {
|
||||||
|
onWait(false);
|
||||||
|
void api.files.dropUpload(place, upload);
|
||||||
}
|
}
|
||||||
sent += size;
|
sent += size;
|
||||||
done += b.length;
|
done += b.length;
|
||||||
@@ -1693,7 +1720,6 @@ export function FileBrowser({
|
|||||||
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ctx.menu && (
|
{ctx.menu && (
|
||||||
|
|||||||
@@ -489,8 +489,9 @@ function FileActions({
|
|||||||
title={t("changes.discardConfirm")}
|
title={t("changes.discardConfirm")}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onAct("discard", f.path, true)}
|
onClick={() => onAct("discard", f.path, true)}
|
||||||
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
|
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
{t("changes.discardShortConfirm")}
|
{t("changes.discardShortConfirm")}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -373,12 +373,17 @@ export function LaneGitHeader({
|
|||||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||||
title={`${git.head} ${git.subject}`}
|
title={`${git.head} ${git.subject}`}
|
||||||
>
|
>
|
||||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
{/* A long branch name truncates too (after the subject has gone),
|
||||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
or it overflowed onto the head and the action icons. */}
|
||||||
{git.branch}
|
<span
|
||||||
|
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
|
||||||
|
title={git.branch}
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||||
|
<span className="truncate">{git.branch}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function SessionReplay({
|
|||||||
{livePane ? (
|
{livePane ? (
|
||||||
<div
|
<div
|
||||||
data-testid="session-replay-live"
|
data-testid="session-replay-live"
|
||||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||||
<button
|
<button
|
||||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
|||||||
) : session && !resumable ? (
|
) : session && !resumable ? (
|
||||||
<div
|
<div
|
||||||
data-testid="session-replay-no-resume"
|
data-testid="session-replay-no-resume"
|
||||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||||
<button
|
<button
|
||||||
@@ -172,7 +172,7 @@ export function SessionReplay({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||||
<PromptEditor
|
<PromptEditor
|
||||||
bare
|
bare
|
||||||
images={images}
|
images={images}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* @file UrlPreview.tsx
|
||||||
|
* @description The session panel's Preview tab: a web page (a lane's dev
|
||||||
|
* server, mostly) shown beside the console. One bar — address, Reload, Open
|
||||||
|
* in a browser tab — and, before anything is open, the workspace's own ports
|
||||||
|
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
|
||||||
|
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
|
||||||
|
* http(s), and the sandbox leaves out top navigation so a page cannot move
|
||||||
|
* the dashboard away. A site that forbids framing (most login pages) shows
|
||||||
|
* blank or as the browser's broken-page icon; that is the browser's rule and
|
||||||
|
* cannot be read from here, so any page not on this machine gets a strip
|
||||||
|
* saying so with the Open button beside it. The
|
||||||
|
* last address is remembered per workspace in localStorage (a viewer
|
||||||
|
* convenience, never the server's).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ExternalLink, Globe, RotateCw } from "lucide-react";
|
||||||
|
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
|
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
|
||||||
|
|
||||||
|
function readSaved(laneId: number): string {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(KEY(laneId)) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The frame's sandbox: everything a web app needs, minus moving the
|
||||||
|
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
|
||||||
|
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
|
||||||
|
const FRAME_SANDBOX =
|
||||||
|
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
|
||||||
|
|
||||||
|
export function UrlPreview({
|
||||||
|
laneId,
|
||||||
|
ports,
|
||||||
|
request,
|
||||||
|
}: {
|
||||||
|
laneId: number;
|
||||||
|
/** The lane's bound ports, by name — the chips of the empty state. */
|
||||||
|
ports: Record<string, number>;
|
||||||
|
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
|
||||||
|
request: { url: string; nonce: number } | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const [url, setUrl] = useState(() => readSaved(laneId));
|
||||||
|
const [draft, setDraft] = useState(url);
|
||||||
|
const [reload, setReload] = useState(0);
|
||||||
|
|
||||||
|
// Another workspace: its own last page.
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = readSaved(laneId);
|
||||||
|
setUrl(saved);
|
||||||
|
setDraft(saved);
|
||||||
|
}, [laneId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (request) go(request.url);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
|
||||||
|
}, [request?.nonce]);
|
||||||
|
|
||||||
|
function go(raw: string) {
|
||||||
|
const next = normalizePreviewUrl(raw);
|
||||||
|
if (!next) {
|
||||||
|
toast.error(t("preview.badUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUrl(next);
|
||||||
|
setDraft(next);
|
||||||
|
setReload((n) => n + 1);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(KEY(laneId), next);
|
||||||
|
} catch {
|
||||||
|
/* disabled storage: the tab just forgets between visits */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTab = () => url && window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
|
||||||
|
const btn =
|
||||||
|
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
|
||||||
|
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
go(draft);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="localhost:3000"
|
||||||
|
aria-label={t("preview.address")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={btn}
|
||||||
|
disabled={!url}
|
||||||
|
onClick={() => setReload((n) => n + 1)}
|
||||||
|
title={t("preview.reload")}
|
||||||
|
aria-label={t("preview.reload")}
|
||||||
|
>
|
||||||
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={btn}
|
||||||
|
disabled={!url}
|
||||||
|
onClick={openTab}
|
||||||
|
title={t("preview.openTab")}
|
||||||
|
aria-label={t("preview.openTab")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{url && !isLoopbackUrl(url) && (
|
||||||
|
// An outside site: most refuse to be framed, and a refusal cannot be
|
||||||
|
// detected from here — so say it, with the way out beside it.
|
||||||
|
<div
|
||||||
|
data-testid="url-preview-blocked-hint"
|
||||||
|
className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-2 px-2 py-1.5 text-[11px] text-fg-secondary"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">{t("preview.blockedHint")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openTab}
|
||||||
|
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-accent hover:bg-surface-4"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
{t("preview.openTabShort")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{url ? (
|
||||||
|
<iframe
|
||||||
|
key={`${url}#${reload}`}
|
||||||
|
src={url}
|
||||||
|
title={t("preview.title")}
|
||||||
|
sandbox={FRAME_SANDBOX}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||||
|
data-testid="url-preview-frame"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||||
|
<Globe className="h-6 w-6 text-fg-muted" />
|
||||||
|
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
|
||||||
|
{portList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
|
{portList.map(([name, port]) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => go(`localhost:${port}`)}
|
||||||
|
title={name}
|
||||||
|
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
localhost:{port}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -148,7 +148,7 @@ function HomeBody({
|
|||||||
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||||
const profile = proxyCapable
|
const profile = proxyCapable
|
||||||
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
? (proxies?.find((r) => r.name === proxy && !r.builtin && !r.company) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const proxyModels: ModelChoice[] | undefined = profile
|
const proxyModels: ModelChoice[] | undefined = profile
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||||
source ? { lane, source } : session ? { lane, session } : lane,
|
source ? { lane, source } : session ? { lane, session } : lane,
|
||||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||||
|
newUploadId: (() => {
|
||||||
|
let n = 0;
|
||||||
|
return () => `upload-id-${String(++n).padStart(8, "0")}`;
|
||||||
|
})(),
|
||||||
api: {
|
api: {
|
||||||
lanes: {
|
lanes: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
@@ -52,8 +56,9 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
allTrash: vi.fn(),
|
allTrash: vi.fn(),
|
||||||
restore: vi.fn(),
|
restore: vi.fn(),
|
||||||
purge: vi.fn(),
|
purge: vi.fn(),
|
||||||
upload: vi.fn(),
|
uploadStaged: vi.fn(),
|
||||||
uploadBatch: vi.fn(),
|
uploadBatchStaged: vi.fn(),
|
||||||
|
dropUpload: vi.fn(async () => null),
|
||||||
uploadCheck: vi.fn(),
|
uploadCheck: vi.fn(),
|
||||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||||
},
|
},
|
||||||
@@ -236,7 +241,7 @@ describe("FileExplorer", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("asks about a taken name on upload, and sends the choice", async () => {
|
it("asks about a taken name on upload, and sends the choice", async () => {
|
||||||
vi.mocked(api.files.upload)
|
vi.mocked(api.files.uploadStaged)
|
||||||
.mockRejectedValueOnce(
|
.mockRejectedValueOnce(
|
||||||
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
||||||
)
|
)
|
||||||
@@ -249,20 +254,24 @@ describe("FileExplorer", () => {
|
|||||||
const ask = await screen.findByTestId("fx-conflict");
|
const ask = await screen.findByTestId("fx-conflict");
|
||||||
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.files.upload).toHaveBeenLastCalledWith(
|
expect(api.files.uploadStaged).toHaveBeenLastCalledWith(
|
||||||
1,
|
1,
|
||||||
"a.txt",
|
"a.txt",
|
||||||
file,
|
file,
|
||||||
"keep",
|
"keep",
|
||||||
expect.any(Function),
|
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||||
expect.any(AbortSignal)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
// The retry names the SAME upload, so the staged bytes are not sent again,
|
||||||
|
// and the staged upload is dropped once answered.
|
||||||
|
const [first, second] = vi.mocked(api.files.uploadStaged).mock.calls.map((c) => c[4].upload);
|
||||||
|
expect(second).toBe(first);
|
||||||
|
await waitFor(() => expect(api.files.dropUpload).toHaveBeenCalledWith(1, first));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends many small files as one batch, asking once about every taken name", async () => {
|
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||||
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
vi.mocked(api.files.uploadBatchStaged).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
@@ -271,33 +280,32 @@ describe("FileExplorer", () => {
|
|||||||
const ask = await screen.findByTestId("fx-conflict");
|
const ask = await screen.findByTestId("fx-conflict");
|
||||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
expect(api.files.uploadBatchStaged).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
"",
|
"",
|
||||||
files.map((file) => ({ file, rel: file.name })),
|
files.map((file) => ({ file, rel: file.name })),
|
||||||
"replace",
|
"replace",
|
||||||
expect.any(Function),
|
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||||
expect.any(AbortSignal)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||||
expect(api.files.upload).not.toHaveBeenCalled();
|
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to one request per file when the server cannot take a batch", async () => {
|
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(2));
|
||||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
||||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
const mk = (rel: string) => {
|
const mk = (rel: string) => {
|
||||||
@@ -307,8 +315,8 @@ describe("FileExplorer", () => {
|
|||||||
};
|
};
|
||||||
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
||||||
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
||||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(1));
|
||||||
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
|
expect(vi.mocked(api.files.uploadStaged).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||||
expect(toast.error).not.toHaveBeenCalled();
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -320,7 +328,7 @@ describe("FileExplorer", () => {
|
|||||||
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
||||||
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
||||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||||
expect(api.files.upload).not.toHaveBeenCalled();
|
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores from the Trash", async () => {
|
it("restores from the Trash", async () => {
|
||||||
|
|||||||
@@ -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,59 @@
|
|||||||
|
/**
|
||||||
|
* @file UrlPreview.test.tsx
|
||||||
|
* @description The Preview tab: port chips before a page is open, the
|
||||||
|
* address bar opening a page in a sandboxed frame without top navigation,
|
||||||
|
* a link's request opening its page, the dashboard's own origin refused, and
|
||||||
|
* the framing-refusal strip shown for an outside site only.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import "../../../i18n";
|
||||||
|
import { UrlPreview } from "../UrlPreview";
|
||||||
|
|
||||||
|
beforeEach(() => window.localStorage.clear());
|
||||||
|
|
||||||
|
describe("UrlPreview", () => {
|
||||||
|
it("offers the workspace's ports, and a chip opens that page", () => {
|
||||||
|
render(<UrlPreview laneId={1} ports={{ web: 3001 }} request={null} />);
|
||||||
|
fireEvent.click(screen.getByText("localhost:3001"));
|
||||||
|
const frame = screen.getByTestId("url-preview-frame");
|
||||||
|
expect(frame.getAttribute("src")).toBe("http://localhost:3001/");
|
||||||
|
expect(frame.getAttribute("sandbox")).not.toMatch(/top-navigation/);
|
||||||
|
expect(window.localStorage.getItem("agenthub.preview.url.1")).toBe("http://localhost:3001/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens what the address bar names, and refuses the dashboard itself", () => {
|
||||||
|
render(<UrlPreview laneId={2} ports={{}} request={null} />);
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(input, { target: { value: window.location.origin + "/settings" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(screen.queryByTestId("url-preview-frame")).toBeNull();
|
||||||
|
fireEvent.change(input, { target: { value: "127.0.0.1:8080/app" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||||
|
"http://127.0.0.1:8080/app"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns that an outside site may refuse to be framed, never for a local page", () => {
|
||||||
|
render(<UrlPreview laneId={4} ports={{}} request={null} />);
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(input, { target: { value: "localhost:3000" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(screen.queryByTestId("url-preview-blocked-hint")).toBeNull();
|
||||||
|
fireEvent.change(input, { target: { value: "https://youtube.com" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(screen.getByTestId("url-preview-blocked-hint")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a link's request", () => {
|
||||||
|
render(
|
||||||
|
<UrlPreview laneId={3} ports={{}} request={{ url: "http://localhost:5173/", nonce: 1 }} />
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||||
|
"http://localhost:5173/"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
||||||
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
||||||
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
||||||
* and Manage opens the Claude config page on that tab.
|
* and Manage opens the Claude config page on that tab. The menu opens
|
||||||
|
* downward when the box sits too near the top to fit it above.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PANEL =
|
const PANEL =
|
||||||
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||||
|
/** Room the menu and its tallest submenu (Project files: search + list) need. */
|
||||||
|
const MENU_ROOM = 380;
|
||||||
|
|
||||||
|
/** The visible band the button sits in: the viewport, narrowed by any
|
||||||
|
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
|
||||||
|
function clipBand(el: HTMLElement): { top: number; bottom: number } {
|
||||||
|
let top = 0;
|
||||||
|
let bottom = window.innerHeight;
|
||||||
|
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||||
|
const o = getComputedStyle(p);
|
||||||
|
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
|
||||||
|
const r = p.getBoundingClientRect();
|
||||||
|
top = Math.max(top, r.top);
|
||||||
|
bottom = Math.min(bottom, r.bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { top, bottom };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open downward when there is not room above but more below. */
|
||||||
|
function opensDown(el: HTMLElement | null): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const band = clipBand(el);
|
||||||
|
const above = r.top - band.top;
|
||||||
|
const below = band.bottom - r.bottom;
|
||||||
|
return above < MENU_ROOM && below > above;
|
||||||
|
}
|
||||||
|
|
||||||
const ROW =
|
const ROW =
|
||||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||||
|
|
||||||
@@ -195,6 +225,7 @@ export function ChatAddMenu({
|
|||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [down, setDown] = useState(false);
|
||||||
const [sub, setSub] = useState<Sub | null>(null);
|
const [sub, setSub] = useState<Sub | null>(null);
|
||||||
const [context, setContext] = useState<Attachment[] | null>(null);
|
const [context, setContext] = useState<Attachment[] | null>(null);
|
||||||
const [recent, setRecent] = useState<Attachment[]>([]);
|
const [recent, setRecent] = useState<Attachment[]>([]);
|
||||||
@@ -286,6 +317,8 @@ export function ChatAddMenu({
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
// A submenu lines up with the menu's edge nearest the button.
|
||||||
|
const edge = down ? "top-0" : "bottom-0";
|
||||||
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -296,13 +329,17 @@ export function ChatAddMenu({
|
|||||||
aria-label={t("chat.add")}
|
aria-label={t("chat.add")}
|
||||||
title={t("chat.add")}
|
title={t("chat.add")}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => (open ? close() : setOpen(true))}
|
onClick={() => {
|
||||||
|
if (open) return close();
|
||||||
|
setDown(opensDown(ref.current));
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
|
||||||
<Row
|
<Row
|
||||||
icon={<Paperclip className="h-4 w-4" />}
|
icon={<Paperclip className="h-4 w-4" />}
|
||||||
label={t("chat.upload")}
|
label={t("chat.upload")}
|
||||||
@@ -376,13 +413,13 @@ export function ChatAddMenu({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sub === "project" && (
|
{sub === "project" && (
|
||||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
|
||||||
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(sub === "context" || sub === "recent") && (
|
{(sub === "context" || sub === "recent") && (
|
||||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||||
<div className="max-h-72 overflow-y-auto">
|
<div className="max-h-72 overflow-y-auto">
|
||||||
{(sub === "context" ? context : recent)?.length ? (
|
{(sub === "context" ? context : recent)?.length ? (
|
||||||
(sub === "context" ? context! : recent).map((it) => (
|
(sub === "context" ? context! : recent).map((it) => (
|
||||||
@@ -405,7 +442,7 @@ export function ChatAddMenu({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sub === "skills" && (
|
{sub === "skills" && (
|
||||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||||
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
<Row
|
<Row
|
||||||
@@ -417,7 +454,7 @@ export function ChatAddMenu({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sub === "plugins" && (
|
{sub === "plugins" && (
|
||||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||||
<div className="max-h-72 overflow-y-auto">
|
<div className="max-h-72 overflow-y-auto">
|
||||||
{lists && lists.plugins.length === 0 && (
|
{lists && lists.plugins.length === 0 && (
|
||||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
||||||
@@ -443,7 +480,7 @@ export function ChatAddMenu({
|
|||||||
onClick={() => manage("plugins")}
|
onClick={() => manage("plugins")}
|
||||||
/>
|
/>
|
||||||
{shownPlugin && (
|
{shownPlugin && (
|
||||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||||
<div className="max-h-72 overflow-y-auto">
|
<div className="max-h-72 overflow-y-auto">
|
||||||
{entries(
|
{entries(
|
||||||
shownPlugin.items,
|
shownPlugin.items,
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||||
* or an installed, enabled AI tool started bare on its default model, no
|
* or an installed, enabled AI tool started bare on its default model and
|
||||||
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
* the account's Session defaults (permission mode, Codex sandbox/approval/
|
||||||
|
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||||
@@ -72,6 +73,7 @@ import {
|
|||||||
type GroupsUpdate,
|
type GroupsUpdate,
|
||||||
} from "./PaneGrid";
|
} from "./PaneGrid";
|
||||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||||
|
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||||
import {
|
import {
|
||||||
type PaneGroups,
|
type PaneGroups,
|
||||||
MAX_PANES,
|
MAX_PANES,
|
||||||
@@ -140,15 +142,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The start body of a bare tool on its default model, no prompt. Only a
|
/** The start body of a bare tool on its default model, no prompt, in the
|
||||||
* different tool is named, as the home does: the workspace's own keeps
|
* account's Session defaults (Claude's permission mode, Codex's sandbox,
|
||||||
* starting even once disabled. */
|
* approval and bypass) — the same ones the workspace home's prompt box starts
|
||||||
|
* from. Only a different tool is named, as the home does: the workspace's own
|
||||||
|
* keeps starting even once disabled. */
|
||||||
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
|
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
|
||||||
const model = aiTools.byId(id)?.defaultModel;
|
const model = aiTools.byId(id)?.defaultModel;
|
||||||
|
const codex = id === "codex" ? readCodexDefaults() : null;
|
||||||
return {
|
return {
|
||||||
prompt: "",
|
prompt: "",
|
||||||
...(id !== lane.tool ? { tool: id } : {}),
|
...(id !== lane.tool ? { tool: id } : {}),
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
|
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||||
|
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
|
||||||
|
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
@@ -689,8 +727,11 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
// another dashboard on the same database, or the socket dropped) would
|
// another dashboard on the same database, or the socket dropped) would
|
||||||
// otherwise leave Stop up after the turn ended.
|
// otherwise leave Stop up after the turn ended.
|
||||||
intervalMs: working || waiting ? 5000 : undefined,
|
intervalMs: working || waiting ? 5000 : undefined,
|
||||||
|
// The transcript growing re-reads it too: a hook that reached another
|
||||||
|
// dashboard on the same database sends this one no agent_updated, and
|
||||||
|
// without a poll Stop never showed for a turn the chat calls Thinking….
|
||||||
match: (msg) =>
|
match: (msg) =>
|
||||||
msg.type === "agent_updated" &&
|
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
|
||||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -711,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) => {
|
||||||
@@ -759,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(
|
||||||
@@ -809,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));
|
||||||
@@ -855,7 +909,7 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-4">
|
<div className="mx-4">
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<div className="mx-auto w-full max-w-5xl">
|
||||||
{menu && (
|
{menu && (
|
||||||
<PaneMenuCard
|
<PaneMenuCard
|
||||||
// A new question starts with its field closed.
|
// A new question starts with its field closed.
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
* 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,10 +86,12 @@ import {
|
|||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
|
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";
|
||||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||||
|
import { openInPreview } from "../../lib/openInPreview";
|
||||||
|
|
||||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
||||||
|
|
||||||
@@ -453,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();
|
||||||
@@ -462,7 +466,10 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
searchRef.current = search;
|
searchRef.current = search;
|
||||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||||
term.loadAddon(
|
term.loadAddon(
|
||||||
new WebLinksAddon((_e, uri) => {
|
new WebLinksAddon((e, uri) => {
|
||||||
|
// A local page opens in the session panel's Preview (one rule
|
||||||
|
// for every link, `lib/openInPreview.ts`); Ctrl/Cmd keeps a tab.
|
||||||
|
if (!e.ctrlKey && !e.metaKey && !e.shiftKey && openInPreview(uri)) return;
|
||||||
window.open(uri, "_blank", "noopener,noreferrer");
|
window.open(uri, "_blank", "noopener,noreferrer");
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -520,9 +527,38 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
||||||
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
||||||
let ownWheel = false;
|
let ownWheel = false;
|
||||||
|
// Tracked: the reports are this pane's own too — one per
|
||||||
|
// LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm
|
||||||
|
// sends one per event and damps a touchpad ×0.3. SGR, as tmux always
|
||||||
|
// asks for it; at the pointer's cell, with xterm's modifier bits.
|
||||||
|
const wheelState: WheelState = { partial: 0 };
|
||||||
|
const sendWheel = (ev: WheelEvent) => {
|
||||||
|
const screen = term.element?.querySelector(".xterm-screen");
|
||||||
|
const box = screen?.getBoundingClientRect();
|
||||||
|
if (!box || !box.height || !box.width) return;
|
||||||
|
const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows);
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
||||||
|
const col = clamp(
|
||||||
|
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
|
||||||
|
term.cols
|
||||||
|
);
|
||||||
|
const row = clamp(
|
||||||
|
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
|
||||||
|
term.rows
|
||||||
|
);
|
||||||
|
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
||||||
|
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
||||||
|
};
|
||||||
term.attachCustomWheelEventHandler((ev) => {
|
term.attachCustomWheelEventHandler((ev) => {
|
||||||
const mode = term.modes.mouseTrackingMode;
|
const mode = term.modes.mouseTrackingMode;
|
||||||
const tracked = mode !== "none" && mode !== "x10";
|
const tracked = mode !== "none" && mode !== "x10";
|
||||||
|
// Shift+wheel stays xterm's (it reports nothing for it).
|
||||||
|
if (tracked && !ev.shiftKey) {
|
||||||
|
sendWheel(ev);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!tracked && ownWheel) {
|
if (!tracked && ownWheel) {
|
||||||
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||||
@@ -326,13 +327,36 @@ describe("TerminalView", () => {
|
|||||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets the wheel through once the pane's program tracks the mouse", () => {
|
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||||
// tmux with `mouse on` sets this; the wheel must reach it as a real SGR
|
const screenBox = () => {
|
||||||
// report, so returning false here would kill scrolling entirely.
|
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
|
||||||
|
.value as { element: HTMLElement };
|
||||||
|
const screen = document.createElement("div");
|
||||||
|
screen.className = "xterm-screen";
|
||||||
|
screen.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
|
||||||
|
term.element.appendChild(screen);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
|
||||||
|
// tmux with `mouse on` sets this. xterm would send ONE report per event and
|
||||||
|
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
|
||||||
termState.modes.mouseTrackingMode = "any";
|
termState.modes.mouseTrackingMode = "any";
|
||||||
termState.buffer.active.type = "alternate";
|
termState.buffer.active.type = "alternate";
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
|
screenBox();
|
||||||
|
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||||||
|
ws.sent.length = 0;
|
||||||
|
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
|
||||||
|
// A notch of 6 rows: one report, at the cell under the pointer.
|
||||||
|
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||||||
|
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
||||||
|
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
|
||||||
|
// two reports, sent as they add up.
|
||||||
|
ws.sent.length = 0;
|
||||||
|
for (let i = 0; i < 6; i++)
|
||||||
|
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||||
|
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
|
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
|
||||||
@@ -361,10 +385,11 @@ describe("TerminalView", () => {
|
|||||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||||
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
||||||
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
||||||
// Tracked again (a tmux that does report): xterm sends it, never twice.
|
// Tracked again (a tmux that does report): sent once, by the tracked path.
|
||||||
termState.modes.mouseTrackingMode = "any";
|
termState.modes.mouseTrackingMode = "any";
|
||||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
screenBox();
|
||||||
expect(ws.sent).toHaveLength(2);
|
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||||
|
expect(ws.sent).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||||
@@ -501,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", () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,15 +19,33 @@
|
|||||||
* the next one below) — and, for
|
* the next one below) — and, for
|
||||||
* a user with their own Linux account (S3), their own HTTPS
|
* a user with their own Linux account (S3), their own HTTPS
|
||||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||||
* table above it, so this lists them only for a member. The private key and
|
* table above it, so this lists them only for a member. **Commit signing**
|
||||||
|
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
|
||||||
|
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
|
||||||
|
* sign with Delete, a way out of the old account-wide setting and of a
|
||||||
|
* signing setup the user made themselves, and **Import a key you have** takes
|
||||||
|
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
|
||||||
* every token are write-only: the server never returns them. A host identity
|
* every token are write-only: the server never returns them. A host identity
|
||||||
* seen by a non-admin is read-only.
|
* seen by a non-admin is read-only.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
import {
|
||||||
|
Cloud,
|
||||||
|
Copy,
|
||||||
|
FileKey,
|
||||||
|
GitBranch,
|
||||||
|
KeyRound,
|
||||||
|
PenLine,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
import { api, type GitIdentity } from "../../lib/api";
|
import { api, type GitIdentity } from "../../lib/api";
|
||||||
|
import type { Lane } from "../../lib/types";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
@@ -216,6 +234,339 @@ function GitHostForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
|
||||||
|
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
|
||||||
|
* workspaces that need it sign), the keys that can sign, each with Delete,
|
||||||
|
* and Import a key the user already has. The old account-wide setting
|
||||||
|
* (every repo signed) only shows when it is on, with a button that turns it
|
||||||
|
* off; a signing setup the user made themselves in `~/.gitconfig` can be
|
||||||
|
* taken out (their key file stays).
|
||||||
|
*/
|
||||||
|
function SigningCard({
|
||||||
|
id,
|
||||||
|
keys,
|
||||||
|
keyLabel,
|
||||||
|
busy,
|
||||||
|
run,
|
||||||
|
}: {
|
||||||
|
id: GitIdentity;
|
||||||
|
keys: Key[];
|
||||||
|
keyLabel: (keyId: string | null | undefined) => string;
|
||||||
|
busy: boolean;
|
||||||
|
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const auth = useAuth().state;
|
||||||
|
const me = auth.mode === "server" ? auth.user : null;
|
||||||
|
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
|
||||||
|
const laneSigning = id.laneSigning ?? [];
|
||||||
|
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
|
||||||
|
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
api.lanes
|
||||||
|
.list()
|
||||||
|
.then((r) => live && setLanes(r.lanes))
|
||||||
|
.catch(() => live && setLanes([]));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
|
||||||
|
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
|
||||||
|
const laneName = (l: Lane) => l.title || l.cwd;
|
||||||
|
// Still signing, but its workspace is gone from the list (removed): the
|
||||||
|
// folder keeps signing until it is turned off here.
|
||||||
|
const orphans =
|
||||||
|
lanes === null
|
||||||
|
? []
|
||||||
|
: laneSigning
|
||||||
|
.filter((x) => !lanes.some((l) => l.id === x.laneId))
|
||||||
|
.map(
|
||||||
|
(x) =>
|
||||||
|
({
|
||||||
|
id: x.laneId,
|
||||||
|
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
|
||||||
|
cwd: "",
|
||||||
|
}) as Lane
|
||||||
|
);
|
||||||
|
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
|
||||||
|
|
||||||
|
const chooseLane = async (lane: Lane, keyId: string | null) =>
|
||||||
|
run(
|
||||||
|
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
|
||||||
|
keyId
|
||||||
|
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
|
||||||
|
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearForeign = async () => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
|
||||||
|
confirmLabel: t("gitIdentity.signForeignRemove"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(
|
||||||
|
() => api.gitIdentity.setSigning(null, false, true),
|
||||||
|
t("gitIdentity.signForeignRemoved")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteKey = async (k: Key) => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
|
||||||
|
confirmLabel: t("gitIdentity.remove"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(
|
||||||
|
() => api.gitIdentity.removeKeyById(k.id!),
|
||||||
|
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doImport = async () => {
|
||||||
|
if (!form) return;
|
||||||
|
const ok = await run(
|
||||||
|
() =>
|
||||||
|
api.gitIdentity.importKey({
|
||||||
|
privateKey: form.text,
|
||||||
|
...(form.name.trim() ? { name: form.name.trim() } : {}),
|
||||||
|
}),
|
||||||
|
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
|
||||||
|
);
|
||||||
|
if (ok) setForm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFile = async (file: File | undefined) => {
|
||||||
|
if (!file || !form) return;
|
||||||
|
// A key file is a few KB; anything far larger is not one.
|
||||||
|
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
|
||||||
|
const text = await file.text();
|
||||||
|
setForm({ ...form, text });
|
||||||
|
};
|
||||||
|
|
||||||
|
const usable = keys.filter((k) => k.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="space-y-2 rounded-lg border border-border p-3"
|
||||||
|
aria-label={t("gitIdentity.signTitle")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||||
|
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||||
|
{t("gitIdentity.signTitle")}
|
||||||
|
</h3>
|
||||||
|
{id.writable && !form && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setForm({ text: "", name: "" })}
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.importKey")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
|
||||||
|
|
||||||
|
{signing.state === "on" && (
|
||||||
|
<div
|
||||||
|
data-testid="git-signing-legacy"
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
|
||||||
|
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
|
||||||
|
</span>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("gitIdentity.signLegacyOff")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{signing.state === "foreign" && (
|
||||||
|
<div
|
||||||
|
data-testid="git-signing-foreign"
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||||
|
{t("gitIdentity.signForeign", { path: signing.path })}
|
||||||
|
</span>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void clearForeign()}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.signForeignRemove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1" data-testid="git-signing-lanes">
|
||||||
|
<div className="text-[11px] font-medium text-fg-secondary">
|
||||||
|
{t("gitIdentity.signLanes")}
|
||||||
|
</div>
|
||||||
|
{usable.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
|
||||||
|
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
|
||||||
|
) : (
|
||||||
|
[...mine, ...orphans].map((l) => {
|
||||||
|
const on = signedWith(l.id);
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`min-w-0 flex-1 truncate text-xs ${
|
||||||
|
on ? "text-status-success" : "text-fg-primary"
|
||||||
|
}`}
|
||||||
|
title={l.cwd || undefined}
|
||||||
|
>
|
||||||
|
{laneName(l)}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
|
||||||
|
value={on?.key ?? ""}
|
||||||
|
disabled={busy || !id.writable}
|
||||||
|
onChange={(e) => void chooseLane(l, e.target.value || null)}
|
||||||
|
className="select !w-48 !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
<option value="">{t("gitIdentity.signNone")}</option>
|
||||||
|
{usable.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{keyLabel(k.id)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usable.length > 0 && (
|
||||||
|
<div className="space-y-1" data-testid="git-signing-keys">
|
||||||
|
<div className="text-[11px] font-medium text-fg-secondary">
|
||||||
|
{t("gitIdentity.signKeys")}
|
||||||
|
</div>
|
||||||
|
{usable.map((k) => {
|
||||||
|
const used = laneSigning.filter((x) => x.key === k.id).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={k.id}
|
||||||
|
className="flex items-center gap-2 rounded border border-border px-2 py-1"
|
||||||
|
>
|
||||||
|
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
|
||||||
|
<div className="truncate font-mono text-[10px] text-fg-muted">
|
||||||
|
{k.fingerprint}
|
||||||
|
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
onClick={() => void copyText(k.publicKey)}
|
||||||
|
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
|
||||||
|
title={t("gitIdentity.signHostStep")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
|
||||||
|
onClick={() => void deleteKey(k)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<form
|
||||||
|
className="space-y-2 rounded border border-border p-2.5"
|
||||||
|
aria-label={t("gitIdentity.importTitle")}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void doImport();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
|
||||||
|
<label className={`${BTN} cursor-pointer`}>
|
||||||
|
<FileKey className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.importFile")}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => void readFile(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
aria-label={t("gitIdentity.importPaste")}
|
||||||
|
placeholder={t("gitIdentity.importPaste")}
|
||||||
|
value={form.text}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
rows={4}
|
||||||
|
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||||
|
className={`${INPUT} resize-y font-mono text-[10px]`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.importName")}
|
||||||
|
placeholder={t("gitIdentity.importName")}
|
||||||
|
value={form.name}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||||
|
className={`${INPUT} !w-40 font-mono`}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !form.text.trim()}
|
||||||
|
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("gitIdentity.importSubmit")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setForm(null)} className={BTN}>
|
||||||
|
{t("gitCreds.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GitIdentitySettings() {
|
export function GitIdentitySettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [id, setId] = useState<GitIdentity | null>(null);
|
const [id, setId] = useState<GitIdentity | null>(null);
|
||||||
@@ -572,6 +923,10 @@ export function GitIdentitySettings() {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{id.signing !== undefined && (
|
||||||
|
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3 rounded-lg border border-border p-3">
|
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||||
@@ -616,6 +971,11 @@ export function GitIdentitySettings() {
|
|||||||
onRemove={() => removeNamed(k.id!)}
|
onRemove={() => removeNamed(k.id!)}
|
||||||
>
|
>
|
||||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
|
{id.signing?.state === "on" && id.signing.key === k.id && (
|
||||||
|
<div className="text-[10px] text-status-success">
|
||||||
|
{t("gitIdentity.signingBadge")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Step n={3}>
|
<Step n={3}>
|
||||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* @file MemberPolicySettings — Settings → AI tools → Tests on this server:
|
||||||
|
* the admin's server-wide switch that allows or blocks members' Claude from
|
||||||
|
* running test runners and container commands on this box
|
||||||
|
* (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`,
|
||||||
|
* enforced by `scripts/member-guard.js`). Renders nothing outside server mode
|
||||||
|
* with per-user accounts (S3), or for anyone but an admin.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type MemberPolicyState } from "../../lib/api";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function MemberPolicySettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const { isAdmin, local } = usePermissions();
|
||||||
|
const [state, setState] = useState<MemberPolicyState | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (local || !isAdmin) return;
|
||||||
|
api.aiTools
|
||||||
|
.memberPolicy()
|
||||||
|
.then(setState)
|
||||||
|
.catch(() => setState(null));
|
||||||
|
}, [local, isAdmin]);
|
||||||
|
|
||||||
|
if (local || !isAdmin || !state?.available) return null;
|
||||||
|
return (
|
||||||
|
<SettingsSection id="member-policy" title={t("memberPolicy.title")}>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("memberPolicy.switch")}
|
||||||
|
description={t("memberPolicy.hint")}
|
||||||
|
checked={state.allowTests}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={async (on) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setState(await api.aiTools.setMemberPolicy(on));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -222,6 +222,8 @@ export function ReportSettings() {
|
|||||||
value={String(form.value.hour)}
|
value={String(form.value.hour)}
|
||||||
onChange={(v) => set({ hour: Number(v) })}
|
onChange={(v) => set({ hour: Number(v) })}
|
||||||
label={t("reports.hour")}
|
label={t("reports.hour")}
|
||||||
|
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
|
||||||
|
searchable={false}
|
||||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +38,13 @@ import {
|
|||||||
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";
|
||||||
@@ -129,7 +136,7 @@ export function ToolProfilesSettings() {
|
|||||||
<PoolLine count={poolCount(current.id)!} />
|
<PoolLine count={poolCount(current.id)!} />
|
||||||
)}
|
)}
|
||||||
{current.id === "claude" ? (
|
{current.id === "claude" ? (
|
||||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} local={local} />
|
||||||
) : (
|
) : (
|
||||||
<OtherProfiles
|
<OtherProfiles
|
||||||
key={current.id}
|
key={current.id}
|
||||||
@@ -475,7 +482,7 @@ function ActiveBadge() {
|
|||||||
// save button only appears while the live account is unsaved, because one
|
// save button only appears while the live account is unsaved, because one
|
||||||
// account is one profile.
|
// account is one profile.
|
||||||
|
|
||||||
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||||
@@ -492,6 +499,45 @@ 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);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -540,10 +586,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
if (!items) return;
|
if (!items) return;
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
if (autoFetched.current.has(p.name)) continue;
|
if (autoFetched.current.has(p.name)) continue;
|
||||||
|
if (!reveal && isHidden(p)) continue;
|
||||||
autoFetched.current.add(p.name);
|
autoFetched.current.add(p.name);
|
||||||
void checkUsage(p.name);
|
void checkUsage(p.name);
|
||||||
}
|
}
|
||||||
}, [items, checkUsage]);
|
}, [items, checkUsage, isHidden, reveal]);
|
||||||
|
|
||||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||||
/** How many saved profiles hold the same account as `p`. */
|
/** How many saved profiles hold the same account as `p`. */
|
||||||
@@ -587,7 +634,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 +652,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 +673,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,75 +691,80 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items === null && !error && <SkeletonRows n={3} />}
|
{items === null && !error && <SkeletonRows n={3} />}
|
||||||
{items !== null && items.length === 0 && (
|
{items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
|
||||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||||
{t("profiles.empty")}
|
{t("profiles.empty")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{items?.map((p) => (
|
{items
|
||||||
<div
|
?.filter((p) => reveal || !isHidden(p))
|
||||||
key={p.name}
|
.map((p) => (
|
||||||
data-testid={`profile-row-${p.name}`}
|
<div
|
||||||
onContextMenu={(e) => ctx.open(e, p)}
|
key={p.name}
|
||||||
className={rowCls}
|
data-testid={`profile-row-${p.name}`}
|
||||||
>
|
onContextMenu={(e) => ctx.open(e, p)}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
onClickCapture={(e) => toggleRow(e, p)}
|
||||||
<LabelEdit
|
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||||
label={p.label}
|
>
|
||||||
busy={busy !== null}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
editing={renaming === p.name}
|
<LabelEdit
|
||||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
label={p.label}
|
||||||
onRename={(next) =>
|
busy={busy !== null}
|
||||||
void run(
|
editing={renaming === p.name}
|
||||||
`rename:${p.name}`,
|
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||||
() => api.ccConfig.renameProfile(p.name, next),
|
onRename={(next) =>
|
||||||
t("profiles.renameSuccess", { name: next })
|
void run(
|
||||||
)
|
`rename:${p.name}`,
|
||||||
}
|
() => api.ccConfig.renameProfile(p.name, next),
|
||||||
|
t("profiles.renameSuccess", { name: next })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{shownActive(p) && <ActiveBadge />}
|
||||||
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
|
{p.email || p.displayName || p.accountUuid}
|
||||||
|
</span>
|
||||||
|
{!shownActive(p) && (
|
||||||
|
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||||
|
{busy === `switch:${p.name}`
|
||||||
|
? t("profiles.switching")
|
||||||
|
: t("profiles.switchButton")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||||
|
{deletable(p) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => void remove(p)}
|
||||||
|
title={t("profiles.deleteButton")}
|
||||||
|
aria-label={t("profiles.deleteButton")}
|
||||||
|
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<IdentityLine identity={p} />
|
||||||
|
<ProfileUsageLine
|
||||||
|
usage={usage[p.name]}
|
||||||
|
error={usageError[p.name]}
|
||||||
|
busy={!!usageBusy[p.name]}
|
||||||
|
disabled={busy !== null || !!usageBusy[p.name]}
|
||||||
|
onCheck={() => void checkUsage(p.name)}
|
||||||
/>
|
/>
|
||||||
{p.active && <ActiveBadge />}
|
{p.savedAt && (
|
||||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
<div className="text-[10px] text-fg-muted mt-1">
|
||||||
{p.email || p.displayName || p.accountUuid}
|
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||||
</span>
|
</div>
|
||||||
{!p.active && (
|
|
||||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
|
||||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
|
||||||
{deletable(p) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy !== null}
|
|
||||||
onClick={() => void remove(p)}
|
|
||||||
title={t("profiles.deleteButton")}
|
|
||||||
aria-label={t("profiles.deleteButton")}
|
|
||||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<IdentityLine identity={p} />
|
))}
|
||||||
<ProfileUsageLine
|
|
||||||
usage={usage[p.name]}
|
|
||||||
error={usageError[p.name]}
|
|
||||||
busy={!!usageBusy[p.name]}
|
|
||||||
disabled={busy !== null || !!usageBusy[p.name]}
|
|
||||||
onCheck={() => void checkUsage(p.name)}
|
|
||||||
/>
|
|
||||||
{p.savedAt && (
|
|
||||||
<div className="text-[10px] text-fg-muted mt-1">
|
|
||||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{ctx.menu && (
|
{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>
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,11 @@
|
|||||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||||
* the sections never mix; "Add another key" makes a named key as one more
|
* the sections never mix; "Add another key" makes a named key as one more
|
||||||
* card in its own tab, a second AWS account is saved from that card under
|
* card in its own tab, a second AWS account is saved from that card under
|
||||||
* the key's name with that key, and a second GitHub key is tied to its host.
|
* the key's name with that key, and a second GitHub key is tied to its host;
|
||||||
|
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
|
||||||
|
* workspace's picker signs that workspace only, a signing key is deleted from
|
||||||
|
* the card after a confirm, and the old account-wide setting and a setup the
|
||||||
|
* user made themselves each have a way out.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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";
|
||||||
@@ -31,6 +35,18 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
saveHttps: vi.fn(),
|
saveHttps: vi.fn(),
|
||||||
saveAuthor: vi.fn(),
|
saveAuthor: vi.fn(),
|
||||||
removeHttps: vi.fn(),
|
removeHttps: vi.fn(),
|
||||||
|
importKey: vi.fn(),
|
||||||
|
setSigning: vi.fn(),
|
||||||
|
setLaneSigning: vi.fn(),
|
||||||
|
},
|
||||||
|
lanes: {
|
||||||
|
list: vi.fn(async () => ({
|
||||||
|
lanes: [
|
||||||
|
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
|
||||||
|
{ id: 2, title: "", cwd: "/w/docs" },
|
||||||
|
],
|
||||||
|
counts: {},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -38,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
|
|||||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
||||||
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
||||||
|
|
||||||
|
const OFF = { state: "off" as const, key: null, path: null };
|
||||||
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
||||||
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||||
account: "member",
|
account: "member",
|
||||||
@@ -335,4 +352,130 @@ describe("GitIdentitySettings", () => {
|
|||||||
});
|
});
|
||||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports a key without signing anything by itself", async () => {
|
||||||
|
const signingKey = {
|
||||||
|
...KEY,
|
||||||
|
id: "id_ed25519_signing",
|
||||||
|
type: "ed25519" as const,
|
||||||
|
name: "signing",
|
||||||
|
};
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
|
||||||
|
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
|
||||||
|
imported: signingKey,
|
||||||
|
signError: null,
|
||||||
|
});
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
|
||||||
|
const form = screen.getByRole("form", { name: "Import an existing private key" });
|
||||||
|
await userEvent.type(
|
||||||
|
within(form).getByLabelText(/paste the private key/),
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----x"
|
||||||
|
);
|
||||||
|
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
|
||||||
|
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
|
||||||
|
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
|
||||||
|
});
|
||||||
|
// Now there is a key: each workspace gets its own picker, all "Don't sign".
|
||||||
|
expect(
|
||||||
|
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
|
||||||
|
).toHaveValue("");
|
||||||
|
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||||
|
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs one workspace only, and stops it", async () => {
|
||||||
|
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setLaneSigning)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
|
||||||
|
await userEvent.selectOptions(pick, k.id);
|
||||||
|
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
|
||||||
|
).toBeTruthy();
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
|
||||||
|
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
|
||||||
|
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a removed workspace that still signs, so it can be turned off", async () => {
|
||||||
|
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const pick = await screen.findByLabelText(
|
||||||
|
"Signing key for Workspace #9 (removed)",
|
||||||
|
{},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
expect(pick).toHaveValue(k.id);
|
||||||
|
await userEvent.selectOptions(pick, "");
|
||||||
|
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a signing key from the signing card, after asking", async () => {
|
||||||
|
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({
|
||||||
|
sshKeys: [k],
|
||||||
|
signing: { state: "off", key: null, path: null },
|
||||||
|
laneSigning: [{ laneId: 1, key: k.id }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||||
|
view({ sshKeys: [], signing: OFF, laneSigning: [] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
|
||||||
|
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
|
||||||
|
expect(confirmDialog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
|
||||||
|
);
|
||||||
|
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns off the old account-wide signing and removes a setup the user made", async () => {
|
||||||
|
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const legacy = await screen.findByTestId("git-signing-legacy");
|
||||||
|
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
|
||||||
|
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
|
||||||
|
const foreign = await screen.findByTestId("git-signing-foreign");
|
||||||
|
expect(foreign).toHaveTextContent("~/.ssh/mine");
|
||||||
|
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
|
||||||
|
expect(confirmDialog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
|
||||||
|
);
|
||||||
|
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for MemberPolicySettings — the admin's "allow members to run
|
||||||
|
* tests" switch in Settings → AI tools: hidden in local mode, for a non-admin
|
||||||
|
* and outside S3, and a toggle saves through PUT /api/ai-tools/member-policy.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import "../../../i18n";
|
||||||
|
import { MemberPolicySettings } from "../MemberPolicySettings";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
|
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||||
|
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const get = vi.mocked(api.aiTools.memberPolicy);
|
||||||
|
const set = vi.mocked(api.aiTools.setMemberPolicy);
|
||||||
|
|
||||||
|
describe("MemberPolicySettings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
perms.isAdmin = true;
|
||||||
|
perms.local = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an admin allow tests, off by default", async () => {
|
||||||
|
get.mockResolvedValue({ available: true, allowTests: false });
|
||||||
|
set.mockResolvedValue({ available: true, allowTests: true });
|
||||||
|
render(<MemberPolicySettings />);
|
||||||
|
const sw = await screen.findByRole("switch", { name: "Allow members to run tests" });
|
||||||
|
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||||
|
fireEvent.click(sw);
|
||||||
|
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||||
|
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides outside S3", async () => {
|
||||||
|
get.mockResolvedValue({ available: false, allowTests: false });
|
||||||
|
const { container } = render(<MemberPolicySettings />);
|
||||||
|
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks nothing for a non-admin or in local mode", () => {
|
||||||
|
perms.isAdmin = false;
|
||||||
|
const { container } = render(<MemberPolicySettings />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
perms.isAdmin = true;
|
||||||
|
perms.local = true;
|
||||||
|
render(<MemberPolicySettings />);
|
||||||
|
expect(get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,6 +68,7 @@ async function show() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
perms.local = true;
|
perms.local = true;
|
||||||
m.cc.profiles.mockResolvedValue({
|
m.cc.profiles.mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
@@ -137,6 +138,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", () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file UserAvatar.tsx
|
* @file UserAvatar.tsx
|
||||||
* @description An account's round picture: the signed-in account's own
|
* @description An account's round picture: the signed-in account's own
|
||||||
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
||||||
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
* /api/users/:id/avatar`, any signed-in account), versioned by `avatarAt`; without one —
|
||||||
* or when the image fails to load — the first letters of its name. Used by the
|
* or when the image fails to load — the first letters of its name. Used by the
|
||||||
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
||||||
* Users page and its editor, the pool board and member picker, and System
|
* Users page and its editor, the pool board and member picker, and System
|
||||||
@@ -23,7 +23,7 @@ export function UserAvatar({
|
|||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
avatarAt?: string | null;
|
avatarAt?: string | null;
|
||||||
/** Another account's (staff only); omitted = the signed-in account's own. */
|
/** Another account's; omitted = the signed-in account's own. */
|
||||||
userId?: number;
|
userId?: number;
|
||||||
/** How many letters of the name stand in for a missing picture. */
|
/** How many letters of the name stand in for a missing picture. */
|
||||||
letters?: number;
|
letters?: number;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { AiToolIcon } from "../AiToolIcon";
|
|||||||
import { SessionStatusBadge } from "../StatusBadge";
|
import { SessionStatusBadge } from "../StatusBadge";
|
||||||
import { MiniChart } from "../admin/MiniChart";
|
import { MiniChart } from "../admin/MiniChart";
|
||||||
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
||||||
|
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
|
||||||
const POOL_LABEL: Record<PoolTool, string> = {
|
const POOL_LABEL: Record<PoolTool, string> = {
|
||||||
claude: "Claude Code",
|
claude: "Claude Code",
|
||||||
@@ -35,6 +37,7 @@ const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
|||||||
|
|
||||||
export function UserDetail({ userId }: { userId: number }) {
|
export function UserDetail({ userId }: { userId: number }) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const { label: toolLabel } = useAiTools();
|
const { label: toolLabel } = useAiTools();
|
||||||
const [data, setData] = useState<UserDetails | null>(null);
|
const [data, setData] = useState<UserDetails | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -289,6 +292,12 @@ export function UserDetail({ userId }: { userId: number }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Their own machines (AgentHub local) on the company hub, and those
|
||||||
|
machines' Company-account sessions — revocable by an admin. */}
|
||||||
|
<Panel title={t("detail.devicesTitle")}>
|
||||||
|
<RemoteDevices userId={userId} canRevoke={isAdmin} />
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"status_duration": "Stuck agent",
|
"status_duration": "Stuck agent",
|
||||||
"token_threshold": "Token threshold",
|
"token_threshold": "Token threshold",
|
||||||
"cost_threshold": "Cost threshold",
|
"cost_threshold": "Cost threshold",
|
||||||
"budget": "Spend budget"
|
"budget": "Spend budget",
|
||||||
|
"remote_unmonitored": "Company account outside AgentHub"
|
||||||
},
|
},
|
||||||
"ruleTypeHints": {
|
"ruleTypeHints": {
|
||||||
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
|
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
|
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
|
||||||
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
|
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
|
||||||
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
|
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
|
||||||
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool."
|
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool.",
|
||||||
|
"remote_unmonitored": "Fires when a user's machine uses the company account for a session it did not start from AgentHub (nothing of it is sent to the hub). Server mode only."
|
||||||
},
|
},
|
||||||
"ruleDesc": {
|
"ruleDesc": {
|
||||||
"eventPattern": "Matches {{pattern}}",
|
"eventPattern": "Matches {{pattern}}",
|
||||||
@@ -26,7 +28,8 @@
|
|||||||
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
||||||
"tokenThreshold": "Session tokens exceed {{tokens}}",
|
"tokenThreshold": "Session tokens exceed {{tokens}}",
|
||||||
"costThreshold": "Session cost reaches {{usd}}",
|
"costThreshold": "Session cost reaches {{usd}}",
|
||||||
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}"
|
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}",
|
||||||
|
"remoteUnmonitored": "session not started from AgentHub"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Alert rules",
|
"title": "Alert rules",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"share": "Share",
|
"share": "Share",
|
||||||
"unshare": "Unshare",
|
"unshare": "Unshare",
|
||||||
"shareDone": "Shared to the Hub",
|
"shareDone": "Shared to the Hub",
|
||||||
"unshareDone": "Removed from the Hub",
|
"unshareDone": "No longer shared",
|
||||||
"reshare": "Share again",
|
"reshare": "Share again",
|
||||||
"reshareDone": "New version shared — people who installed it are offered Update",
|
"reshareDone": "New version shared — people who installed it are offered Update",
|
||||||
"reshareSame": "Already the newest version — shared",
|
"reshareSame": "Already the newest version — shared",
|
||||||
@@ -234,5 +234,50 @@
|
|||||||
"pack.updateN": "Update {{count}}",
|
"pack.updateN": "Update {{count}}",
|
||||||
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
||||||
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
||||||
"pack.updated": "Updated {{ok}} · failed {{failed}}"
|
"pack.updated": "Updated {{ok}} · failed {{failed}}",
|
||||||
|
"tab.withme": "Shared with you",
|
||||||
|
"sharing": "Sharing…",
|
||||||
|
"shareTitle": "Share “{{title}}”",
|
||||||
|
"shareMode.people": "Only the people I pick",
|
||||||
|
"shareMode.peopleHint": "Only them (and admins) see it; nobody else finds it in the Hub.",
|
||||||
|
"shareMode.everyone": "Everyone (the Hub)",
|
||||||
|
"shareMode.everyoneHint": "Every user sees it in the Hub.",
|
||||||
|
"shareMode.nobody": "Stop sharing",
|
||||||
|
"shareMode.nobodyHint": "Only you (and admins) see it again.",
|
||||||
|
"sharePeople.search": "Find a person",
|
||||||
|
"sharePeople.none": "Nobody matches.",
|
||||||
|
"sharePeople.picked_one": "{{count}} person picked",
|
||||||
|
"sharePeople.picked_other": "{{count}} people picked",
|
||||||
|
"shareSave": "Save",
|
||||||
|
"sharePeopleDone_one": "Shared with {{count}} person",
|
||||||
|
"sharePeopleDone_other": "Shared with {{count}} people",
|
||||||
|
"sharedWithCount_one": "Shared with {{count}} person",
|
||||||
|
"sharedWithCount_other": "Shared with {{count}} people",
|
||||||
|
"sharedWithMe": "Shared with you",
|
||||||
|
"withme.empty": "Nothing has been shared with you by name yet.",
|
||||||
|
"folders.all": "All folders",
|
||||||
|
"folders.new": "New folder",
|
||||||
|
"folders.newName": "Folder name",
|
||||||
|
"folders.rename": "Rename",
|
||||||
|
"folders.delete": "Delete folder",
|
||||||
|
"folders.deleteTitle": "Delete the folder “{{name}}”?",
|
||||||
|
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
|
||||||
|
"folders.empty": "This folder is empty. 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. 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",
|
||||||
@@ -262,6 +263,7 @@
|
|||||||
"jobs.expand": "Show background tasks",
|
"jobs.expand": "Show background tasks",
|
||||||
"uploads.stop": "Stop upload",
|
"uploads.stop": "Stop upload",
|
||||||
"uploads.stopConfirm": "Click again to stop",
|
"uploads.stopConfirm": "Click again to stop",
|
||||||
|
"uploads.waiting": "The dashboard is restarting — the upload resumes when it is back.",
|
||||||
"uploads.stopping": "Stopping…",
|
"uploads.stopping": "Stopping…",
|
||||||
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
|
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
|
||||||
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
|
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
|
||||||
@@ -275,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",
|
||||||
@@ -385,6 +388,16 @@
|
|||||||
"menu.remove": "Delete workspace…",
|
"menu.remove": "Delete workspace…",
|
||||||
"panel.changes": "Changes",
|
"panel.changes": "Changes",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
|
"panel.preview": "Preview",
|
||||||
|
"preview.title": "Page preview",
|
||||||
|
"preview.address": "Page address",
|
||||||
|
"preview.reload": "Reload",
|
||||||
|
"preview.openTab": "Open in a browser tab",
|
||||||
|
"preview.badUrl": "Can't open that address — only an http(s) page other than AgentHub itself.",
|
||||||
|
"preview.empty": "Type an address, or click a localhost link in the chat or terminal.",
|
||||||
|
"preview.loginHint": "A blank page? The site may forbid embedding or need a login — open it in a browser tab.",
|
||||||
|
"preview.blockedHint": "Sites like YouTube, Google or a login page refuse to show inside another page. A blank page or a broken-page icon means that — open it in a browser tab.",
|
||||||
|
"preview.openTabShort": "Open tab",
|
||||||
"replay.placeholder": "Message to continue this session…",
|
"replay.placeholder": "Message to continue this session…",
|
||||||
"replay.hint": "Sending resumes the session in a free pane.",
|
"replay.hint": "Sending resumes the session in a free pane.",
|
||||||
"replay.send": "Send",
|
"replay.send": "Send",
|
||||||
@@ -635,6 +648,7 @@
|
|||||||
"fx.pasted": "Pasted.",
|
"fx.pasted": "Pasted.",
|
||||||
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
||||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
||||||
|
"fx.secret": "Hidden folders at the top of a home (.ssh, .config…) are not editable here — use a terminal. For an SSH key to clone or sign commits: Settings → Git.",
|
||||||
"fx.conflictTitle": "Already there",
|
"fx.conflictTitle": "Already there",
|
||||||
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
||||||
"fx.conflictAll": "Do this for every remaining file",
|
"fx.conflictAll": "Do this for every remaining file",
|
||||||
@@ -736,7 +750,7 @@
|
|||||||
"shelves.deleted": "Shelf deleted",
|
"shelves.deleted": "Shelf deleted",
|
||||||
"shelves.restored": "Shelved changes restored",
|
"shelves.restored": "Shelved changes restored",
|
||||||
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
|
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
|
||||||
"shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf",
|
"shelves.restoreDirty": "Commit, discard or unstage the changes in this shelf's files (and anything staged) before restoring it",
|
||||||
"shelves.shelveTicked": "Shelve ({{count}})",
|
"shelves.shelveTicked": "Shelve ({{count}})",
|
||||||
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
|
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
|
||||||
"shelves.shelveApply": "Shelve {{count}} file(s)",
|
"shelves.shelveApply": "Shelve {{count}} file(s)",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"unmute": "Unmute",
|
"unmute": "Unmute",
|
||||||
"clearAlerts": "Clear alerts",
|
"clearAlerts": "Clear alerts",
|
||||||
"send": "Send",
|
|
||||||
"rename": "Click to rename {{name}}",
|
"rename": "Click to rename {{name}}",
|
||||||
"renameLabel": "Pet name",
|
"renameLabel": "Pet name",
|
||||||
"renameHint": "Enter to save · empty = default name",
|
"renameHint": "Enter to save · empty = default name",
|
||||||
@@ -53,13 +52,15 @@
|
|||||||
"attention": "Needs you",
|
"attention": "Needs you",
|
||||||
"attentionError": "errored",
|
"attentionError": "errored",
|
||||||
"attentionWaiting": "waiting on you",
|
"attentionWaiting": "waiting on you",
|
||||||
"attentionMore": "+{{count}} more in Sessions"
|
"attentionMore": "+{{count}} more in Sessions",
|
||||||
|
"label": "Pet {{name}}",
|
||||||
|
"close": "Close {{name}}",
|
||||||
|
"status": "Status — click to show the sessions that need you"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Dismiss",
|
"bubbleDismiss": "Dismiss",
|
||||||
"open": "Open {{name}} companion",
|
"open": "Open {{name}} companion",
|
||||||
"closeAvatar": "Close {{name}}",
|
"closeAvatar": "Close {{name}}",
|
||||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||||
"startFailed": "Couldn't start that session: {{error}}",
|
|
||||||
"bubbleOpen": "Open",
|
"bubbleOpen": "Open",
|
||||||
"bubbleMore": "{{list}} +{{count}} more",
|
"bubbleMore": "{{list}} +{{count}} more",
|
||||||
"notify": {
|
"notify": {
|
||||||
@@ -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": {
|
||||||
@@ -883,6 +867,40 @@
|
|||||||
"authorName": "Name",
|
"authorName": "Name",
|
||||||
"authorEmail": "Email",
|
"authorEmail": "Email",
|
||||||
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
||||||
|
"signTitle": "Commit signing",
|
||||||
|
"signIntro": "Choose which workspaces sign their commits — from your terminal, the Changes panel or an agent — with one of your SSH keys. Every other repository stays unsigned.",
|
||||||
|
"signLanes": "Workspaces",
|
||||||
|
"signLanesEmpty": "You have no workspace yet.",
|
||||||
|
"signNeedKey": "Import a key you have, or make one below, to sign with.",
|
||||||
|
"signLaneGone": "Workspace #{{id}} (removed)",
|
||||||
|
"signLaneKey": "Signing key for {{name}}",
|
||||||
|
"signLaneSaved": "{{name}} now signs its commits with {{key}}.",
|
||||||
|
"signLaneStopped": "{{name}} no longer signs its commits.",
|
||||||
|
"signLegacy": "Every commit of your account is signed with {{key}} (the old account-wide setting).",
|
||||||
|
"signLegacyOff": "Sign per workspace only",
|
||||||
|
"signLegacyStopped": "Account-wide signing is off — each workspace keeps its own choice.",
|
||||||
|
"signForeign": "Your ~/.gitconfig signs every commit with a key you set up yourself: {{path}}.",
|
||||||
|
"signForeignRemove": "Remove",
|
||||||
|
"signForeignConfirm": "Take {{path}} out of your ~/.gitconfig? Commits stop being signed with it; the key file itself is not deleted.",
|
||||||
|
"signForeignRemoved": "The old signing setup was taken out of ~/.gitconfig.",
|
||||||
|
"signKeys": "Keys you can sign with",
|
||||||
|
"signKeyUsed_one": "signs {{count}} workspace",
|
||||||
|
"signKeyUsed_other": "signs {{count}} workspaces",
|
||||||
|
"signKeyCopy": "Copy the public key of {{key}}",
|
||||||
|
"signKeyDeleteConfirm": "Delete key {{key}}? Its private file is deleted, every workspace signing with it stops signing, and clones that use it can no longer fetch or push.",
|
||||||
|
"signKeyDeleted": "Key {{key}} deleted.",
|
||||||
|
"signKey": "Signing key",
|
||||||
|
"signNone": "Don't sign",
|
||||||
|
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
|
||||||
|
"importKey": "Import a key you have",
|
||||||
|
"importTitle": "Import an existing private key",
|
||||||
|
"importFile": "Choose the key file",
|
||||||
|
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
|
||||||
|
"importName": "Name (optional)",
|
||||||
|
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
|
||||||
|
"importSubmit": "Import",
|
||||||
|
"imported": "Key {{name}} imported.",
|
||||||
|
"signingBadge": "Signs commits",
|
||||||
"authorSaved": "Commit author saved.",
|
"authorSaved": "Commit author saved.",
|
||||||
"title": "Your git identity",
|
"title": "Your git identity",
|
||||||
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
||||||
@@ -904,9 +922,9 @@
|
|||||||
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
||||||
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
|
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
|
||||||
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
|
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
|
||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||||
"gitHubSub": "ED25519 key — the git@… SSH address",
|
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||||
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
"gitHubStepAdd": "Add it to your account on the host and paste the whole line: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Personal settings → Register SSH key.",
|
||||||
"awsTitle": "AWS CodeCommit",
|
"awsTitle": "AWS CodeCommit",
|
||||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||||
"stepCopy": "Copy the public key.",
|
"stepCopy": "Copy the public key.",
|
||||||
@@ -932,10 +950,10 @@
|
|||||||
"aliasPlaceholder": "name (optional)",
|
"aliasPlaceholder": "name (optional)",
|
||||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
|
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
|
||||||
"tabOther": "Other hosts",
|
"tabOther": "Other hosts",
|
||||||
"addKey": "Add another key",
|
"addKey": "Add another key",
|
||||||
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
"namedGitSub": "ED25519 · for one more account on a git host.",
|
||||||
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||||
"namedAwsSub": "RSA · for one more AWS account.",
|
"namedAwsSub": "RSA · for one more AWS account.",
|
||||||
"removeKeyNamed": "Delete key {{name}}"
|
"removeKeyNamed": "Delete key {{name}}"
|
||||||
@@ -1135,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",
|
||||||
@@ -1427,5 +1450,49 @@
|
|||||||
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||||
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||||
"nextSession": "{{account}} is used from your next session"
|
"nextSession": "{{account}} is used from your next session"
|
||||||
|
},
|
||||||
|
"companyHub": {
|
||||||
|
"title": "Company hub",
|
||||||
|
"description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.",
|
||||||
|
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first.",
|
||||||
|
"ruleNotSent": "Sessions on your own login or your own AI Proxy send nothing — not even the folder name. The hub never reads your files and never controls this machine.",
|
||||||
|
"ruleKey": "The company account's credential stays on the hub; this machine keeps only a device token (90 days, revocable).",
|
||||||
|
"ruleOffice": "The hub is on the company network: away from it, Company-account sessions cannot start; your own login works as usual.",
|
||||||
|
"enrolledAs": "Enrolled with {{hub}} as {{user}}",
|
||||||
|
"validUntil": "valid until {{time}}",
|
||||||
|
"expired": "expired",
|
||||||
|
"expiredHint": "This machine's hub login expired — enroll again.",
|
||||||
|
"sendNow": "Send now",
|
||||||
|
"logout": "Log out",
|
||||||
|
"logoutTitle": "Log this machine out of the hub?",
|
||||||
|
"logoutHint": "Its device token is revoked on the hub. Company-account sessions stop working here until you enroll again.",
|
||||||
|
"sentTitle": "Sent to the hub",
|
||||||
|
"sentLine": "{{bytes}} · {{time}}",
|
||||||
|
"notSentYet": "not sent yet",
|
||||||
|
"ended": "ended",
|
||||||
|
"nothingSent": "No Company-account session yet.",
|
||||||
|
"sentToast": "Sent {{bytes}} of {{sessions}} session(s)",
|
||||||
|
"enrolledToast": "This machine is enrolled with the hub",
|
||||||
|
"hubAddress": "Hub address",
|
||||||
|
"username": "Hub username",
|
||||||
|
"password": "Hub password",
|
||||||
|
"enroll": "Enroll this machine",
|
||||||
|
"cliHint": "Or in a terminal: agenthub remote login <hub address>",
|
||||||
|
"hubTitle": "Your machines",
|
||||||
|
"hubDescription": "Machines running AgentHub local that you enrolled with this hub, and their Company-account sessions.",
|
||||||
|
"noDevices": "No machine enrolled.",
|
||||||
|
"revokedAt": "revoked {{time}}",
|
||||||
|
"lastSeen": "seen {{time}} from {{ip}}",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revokeTitle": "Revoke {{name}}?",
|
||||||
|
"revokeHint": "Its device token stops working at once; the machine must enroll again.",
|
||||||
|
"revoked": "{{name}} revoked",
|
||||||
|
"yourPassword": "Your password",
|
||||||
|
"sessions": "Company-account sessions",
|
||||||
|
"unmonitored": "not started from AgentHub",
|
||||||
|
"sessionLine": "{{requests}} requests · {{input}} in · {{output}} out · {{account}}",
|
||||||
|
"purged": "conversation removed (retention)",
|
||||||
|
"whoRead": "Who opened your sessions",
|
||||||
|
"nobodyRead": "Nobody has opened your sessions."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,7 +409,8 @@
|
|||||||
"activeLabel": "Running now",
|
"activeLabel": "Running now",
|
||||||
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
||||||
"nowTitle": "Running now",
|
"nowTitle": "Running now",
|
||||||
"poolTheirs7d": "theirs 7 d {{cost}}"
|
"poolTheirs7d": "theirs 7 d {{cost}}",
|
||||||
|
"devicesTitle": "Machines (AgentHub local)"
|
||||||
},
|
},
|
||||||
"activity": "Activity",
|
"activity": "Activity",
|
||||||
"spend": "Spend today",
|
"spend": "Spend today",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"status_duration": "Agent bị kẹt",
|
"status_duration": "Agent bị kẹt",
|
||||||
"token_threshold": "Ngưỡng token",
|
"token_threshold": "Ngưỡng token",
|
||||||
"cost_threshold": "Ngưỡng chi phí",
|
"cost_threshold": "Ngưỡng chi phí",
|
||||||
"budget": "Ngân sách chi tiêu"
|
"budget": "Ngân sách chi tiêu",
|
||||||
|
"remote_unmonitored": "Tài khoản công ty dùng ngoài AgentHub"
|
||||||
},
|
},
|
||||||
"ruleTypeHints": {
|
"ruleTypeHints": {
|
||||||
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
|
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
|
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
|
||||||
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
|
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
|
||||||
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
|
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
|
||||||
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI."
|
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI.",
|
||||||
|
"remote_unmonitored": "Kích hoạt khi máy của một người dùng tài khoản công ty cho một phiên không mở từ AgentHub (phiên đó không được gửi về hub). Chỉ có ở chế độ server."
|
||||||
},
|
},
|
||||||
"ruleDesc": {
|
"ruleDesc": {
|
||||||
"eventPattern": "Khớp {{pattern}}",
|
"eventPattern": "Khớp {{pattern}}",
|
||||||
@@ -26,7 +28,8 @@
|
|||||||
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
|
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
|
||||||
"tokenThreshold": "Token của phiên vượt {{tokens}}",
|
"tokenThreshold": "Token của phiên vượt {{tokens}}",
|
||||||
"costThreshold": "Chi phí phiên chạm {{usd}}",
|
"costThreshold": "Chi phí phiên chạm {{usd}}",
|
||||||
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}"
|
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}",
|
||||||
|
"remoteUnmonitored": "phiên không mở từ AgentHub"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Quy tắc cảnh báo",
|
"title": "Quy tắc cảnh báo",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"share": "Chia sẻ",
|
"share": "Chia sẻ",
|
||||||
"unshare": "Bỏ chia sẻ",
|
"unshare": "Bỏ chia sẻ",
|
||||||
"shareDone": "Đã chia sẻ lên Hub",
|
"shareDone": "Đã chia sẻ lên Hub",
|
||||||
"unshareDone": "Đã gỡ khỏi Hub",
|
"unshareDone": "Đã ngừng chia sẻ",
|
||||||
"reshare": "Share lại",
|
"reshare": "Share lại",
|
||||||
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
||||||
"reshareSame": "Đã là bản mới nhất — đã share",
|
"reshareSame": "Đã là bản mới nhất — đã share",
|
||||||
@@ -232,5 +232,50 @@
|
|||||||
"pack.updateN": "Cập nhật {{count}}",
|
"pack.updateN": "Cập nhật {{count}}",
|
||||||
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
||||||
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
||||||
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
|
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}",
|
||||||
|
"tab.withme": "Chia sẻ với bạn",
|
||||||
|
"sharing": "Chia sẻ…",
|
||||||
|
"shareTitle": "Chia sẻ “{{title}}”",
|
||||||
|
"shareMode.people": "Chỉ những người tôi chọn",
|
||||||
|
"shareMode.peopleHint": "Chỉ họ (và admin) thấy; người khác không thấy trên Hub.",
|
||||||
|
"shareMode.everyone": "Mọi người (Hub)",
|
||||||
|
"shareMode.everyoneHint": "Mọi người dùng đều thấy trên Hub.",
|
||||||
|
"shareMode.nobody": "Ngừng chia sẻ",
|
||||||
|
"shareMode.nobodyHint": "Chỉ bạn (và admin) thấy như trước.",
|
||||||
|
"sharePeople.search": "Tìm người",
|
||||||
|
"sharePeople.none": "Không có ai khớp.",
|
||||||
|
"sharePeople.picked_one": "Đã chọn {{count}} người",
|
||||||
|
"sharePeople.picked_other": "Đã chọn {{count}} người",
|
||||||
|
"shareSave": "Lưu",
|
||||||
|
"sharePeopleDone_one": "Đã chia sẻ với {{count}} người",
|
||||||
|
"sharePeopleDone_other": "Đã chia sẻ với {{count}} người",
|
||||||
|
"sharedWithCount_one": "Chia sẻ với {{count}} người",
|
||||||
|
"sharedWithCount_other": "Chia sẻ với {{count}} người",
|
||||||
|
"sharedWithMe": "Chia sẻ với bạn",
|
||||||
|
"withme.empty": "Chưa có ai chia sẻ riêng với bạn.",
|
||||||
|
"folders.all": "Tất cả thư mục",
|
||||||
|
"folders.new": "Thư mục mới",
|
||||||
|
"folders.newName": "Tên thư mục",
|
||||||
|
"folders.rename": "Đổi tên",
|
||||||
|
"folders.delete": "Xóa thư mục",
|
||||||
|
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
|
||||||
|
"folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.",
|
||||||
|
"folders.empty": "Thư mục này trống. 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. 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",
|
||||||
@@ -262,6 +263,7 @@
|
|||||||
"jobs.expand": "Xem các việc chạy nền",
|
"jobs.expand": "Xem các việc chạy nền",
|
||||||
"uploads.stop": "Ngưng tải lên",
|
"uploads.stop": "Ngưng tải lên",
|
||||||
"uploads.stopConfirm": "Bấm lần nữa để ngưng",
|
"uploads.stopConfirm": "Bấm lần nữa để ngưng",
|
||||||
|
"uploads.waiting": "Dashboard đang khởi động lại — tải lên sẽ tiếp tục khi có kết nối.",
|
||||||
"uploads.stopping": "Đang ngưng…",
|
"uploads.stopping": "Đang ngưng…",
|
||||||
"uploads.stopped_one": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
"uploads.stopped_one": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||||
"uploads.stopped_other": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
"uploads.stopped_other": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||||
@@ -275,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",
|
||||||
@@ -385,6 +388,16 @@
|
|||||||
"menu.remove": "Xóa workspace…",
|
"menu.remove": "Xóa workspace…",
|
||||||
"panel.changes": "Thay đổi",
|
"panel.changes": "Thay đổi",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
|
"panel.preview": "Xem trang",
|
||||||
|
"preview.title": "Xem trước trang web",
|
||||||
|
"preview.address": "Địa chỉ trang",
|
||||||
|
"preview.reload": "Tải lại",
|
||||||
|
"preview.openTab": "Mở trong tab trình duyệt",
|
||||||
|
"preview.badUrl": "Không mở được địa chỉ này — chỉ mở được trang http(s), không phải chính AgentHub.",
|
||||||
|
"preview.empty": "Nhập địa chỉ, hoặc bấm một link localhost trong chat hay terminal.",
|
||||||
|
"preview.loginHint": "Trang trắng? Trang có thể chặn nhúng hoặc cần đăng nhập — hãy mở trong tab trình duyệt.",
|
||||||
|
"preview.blockedHint": "Các trang như YouTube, Google hay trang đăng nhập không cho hiện bên trong trang khác. Thấy trang trắng hoặc biểu tượng lỗi là vì vậy — hãy mở trong tab trình duyệt.",
|
||||||
|
"preview.openTabShort": "Mở tab",
|
||||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||||
"replay.send": "Gửi",
|
"replay.send": "Gửi",
|
||||||
@@ -635,6 +648,7 @@
|
|||||||
"fx.pasted": "Đã dán.",
|
"fx.pasted": "Đã dán.",
|
||||||
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
||||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
||||||
|
"fx.secret": "Thư mục ẩn ở cấp đầu home (.ssh, .config…) không chỉnh được ở đây — hãy dùng terminal. Cần SSH key để clone hay ký commit: Settings → Git.",
|
||||||
"fx.conflictTitle": "Đã có sẵn",
|
"fx.conflictTitle": "Đã có sẵn",
|
||||||
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||||
@@ -736,7 +750,7 @@
|
|||||||
"shelves.deleted": "Đã xoá shelf",
|
"shelves.deleted": "Đã xoá shelf",
|
||||||
"shelves.restored": "Đã khôi phục thay đổi đã cất",
|
"shelves.restored": "Đã khôi phục thay đổi đã cất",
|
||||||
"shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.",
|
"shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.",
|
||||||
"shelves.restoreDirty": "Hãy commit, bỏ hoặc cất thay đổi hiện tại trước khi khôi phục một shelf",
|
"shelves.restoreDirty": "Hãy commit, bỏ hoặc bỏ stage thay đổi trên các file của shelf này (và mọi thứ đã stage) trước khi khôi phục",
|
||||||
"shelves.shelveTicked": "Cất ({{count}})",
|
"shelves.shelveTicked": "Cất ({{count}})",
|
||||||
"shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc",
|
"shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc",
|
||||||
"shelves.shelveApply": "Cất {{count}} file",
|
"shelves.shelveApply": "Cất {{count}} file",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"mute": "Tắt tiếng",
|
"mute": "Tắt tiếng",
|
||||||
"unmute": "Bật tiếng",
|
"unmute": "Bật tiếng",
|
||||||
"clearAlerts": "Xoá cảnh báo",
|
"clearAlerts": "Xoá cảnh báo",
|
||||||
"send": "Gửi",
|
|
||||||
"rename": "Bấm để đổi tên {{name}}",
|
"rename": "Bấm để đổi tên {{name}}",
|
||||||
"renameLabel": "Tên pet",
|
"renameLabel": "Tên pet",
|
||||||
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
||||||
@@ -53,13 +52,15 @@
|
|||||||
"attention": "Cần bạn xem",
|
"attention": "Cần bạn xem",
|
||||||
"attentionError": "bị lỗi",
|
"attentionError": "bị lỗi",
|
||||||
"attentionWaiting": "đang chờ bạn",
|
"attentionWaiting": "đang chờ bạn",
|
||||||
"attentionMore": "+{{count}} phiên khác trong Phiên"
|
"attentionMore": "+{{count}} phiên khác trong Phiên",
|
||||||
|
"label": "Pet {{name}}",
|
||||||
|
"close": "Đóng {{name}}",
|
||||||
|
"status": "Trạng thái — bấm để xem các phiên cần bạn"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Đóng",
|
"bubbleDismiss": "Đóng",
|
||||||
"open": "Mở trợ lý {{name}}",
|
"open": "Mở trợ lý {{name}}",
|
||||||
"closeAvatar": "Đóng {{name}}",
|
"closeAvatar": "Đóng {{name}}",
|
||||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
||||||
"startFailed": "Không khởi động được phiên: {{error}}",
|
|
||||||
"bubbleOpen": "Mở",
|
"bubbleOpen": "Mở",
|
||||||
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
||||||
"notify": {
|
"notify": {
|
||||||
@@ -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": {
|
||||||
@@ -871,6 +855,40 @@
|
|||||||
"authorName": "Tên",
|
"authorName": "Tên",
|
||||||
"authorEmail": "Email",
|
"authorEmail": "Email",
|
||||||
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
||||||
|
"signTitle": "Ký commit",
|
||||||
|
"signIntro": "Chọn workspace nào ký commit — từ terminal, bảng Changes hay agent — bằng một SSH key của bạn. Các repo khác không ký.",
|
||||||
|
"signLanes": "Workspace",
|
||||||
|
"signLanesEmpty": "Bạn chưa có workspace nào.",
|
||||||
|
"signNeedKey": "Hãy nhập key bạn có, hoặc tạo key ở phần dưới, để dùng ký.",
|
||||||
|
"signLaneGone": "Workspace #{{id}} (đã xóa)",
|
||||||
|
"signLaneKey": "Key ký cho {{name}}",
|
||||||
|
"signLaneSaved": "{{name}} giờ ký commit bằng {{key}}.",
|
||||||
|
"signLaneStopped": "{{name}} không còn ký commit.",
|
||||||
|
"signLegacy": "Mọi commit của tài khoản đang được ký bằng {{key}} (cài đặt cũ cho cả tài khoản).",
|
||||||
|
"signLegacyOff": "Chỉ ký theo workspace",
|
||||||
|
"signLegacyStopped": "Đã tắt ký cho cả tài khoản — mỗi workspace giữ lựa chọn riêng.",
|
||||||
|
"signForeign": "~/.gitconfig của bạn đang ký mọi commit bằng key bạn tự cài: {{path}}.",
|
||||||
|
"signForeignRemove": "Gỡ",
|
||||||
|
"signForeignConfirm": "Gỡ {{path}} khỏi ~/.gitconfig? Commit sẽ không còn ký bằng key này; file key không bị xóa.",
|
||||||
|
"signForeignRemoved": "Đã gỡ cấu hình ký cũ khỏi ~/.gitconfig.",
|
||||||
|
"signKeys": "Key dùng để ký",
|
||||||
|
"signKeyUsed_one": "ký {{count}} workspace",
|
||||||
|
"signKeyUsed_other": "ký {{count}} workspace",
|
||||||
|
"signKeyCopy": "Sao chép public key của {{key}}",
|
||||||
|
"signKeyDeleteConfirm": "Xóa key {{key}}? File khóa riêng sẽ bị xóa, các workspace đang ký bằng key này sẽ ngừng ký, và các bản clone dùng key này không fetch/push được nữa.",
|
||||||
|
"signKeyDeleted": "Đã xóa key {{key}}.",
|
||||||
|
"signKey": "Key dùng để ký",
|
||||||
|
"signNone": "Không ký",
|
||||||
|
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
|
||||||
|
"importKey": "Nhập key có sẵn",
|
||||||
|
"importTitle": "Nhập private key có sẵn",
|
||||||
|
"importFile": "Chọn file key",
|
||||||
|
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
|
||||||
|
"importName": "Tên (không bắt buộc)",
|
||||||
|
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
|
||||||
|
"importSubmit": "Nhập",
|
||||||
|
"imported": "Đã nhập key {{name}}.",
|
||||||
|
"signingBadge": "Ký commit",
|
||||||
"authorSaved": "Đã lưu tác giả commit.",
|
"authorSaved": "Đã lưu tác giả commit.",
|
||||||
"title": "Danh tính git của bạn",
|
"title": "Danh tính git của bạn",
|
||||||
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
||||||
@@ -892,9 +910,9 @@
|
|||||||
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
||||||
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
|
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
|
||||||
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
|
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
|
||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||||
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||||
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
"gitHubStepAdd": "Thêm vào tài khoản trên host, dán nguyên dòng: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Cài đặt cá nhân → Đăng ký SSH key.",
|
||||||
"awsTitle": "AWS CodeCommit",
|
"awsTitle": "AWS CodeCommit",
|
||||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||||
"stepCopy": "Copy public key.",
|
"stepCopy": "Copy public key.",
|
||||||
@@ -920,10 +938,10 @@
|
|||||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
|
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
|
||||||
"tabOther": "Host khác",
|
"tabOther": "Host khác",
|
||||||
"addKey": "Thêm key khác",
|
"addKey": "Thêm key khác",
|
||||||
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
"namedGitSub": "ED25519 · cho thêm một tài khoản trên git host.",
|
||||||
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||||
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||||
"removeKeyNamed": "Xoá key {{name}}"
|
"removeKeyNamed": "Xoá key {{name}}"
|
||||||
@@ -1123,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ủ",
|
||||||
@@ -1415,5 +1438,49 @@
|
|||||||
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
||||||
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
||||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
||||||
|
},
|
||||||
|
"companyHub": {
|
||||||
|
"title": "Hub công ty",
|
||||||
|
"description": "Dùng tài khoản Claude chung của công ty từ máy này. Chỉ phần tốn tiền công ty mới được ghi nhận.",
|
||||||
|
"ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi.",
|
||||||
|
"ruleNotSent": "Session dùng tài khoản riêng hoặc AI Proxy riêng của bạn không gửi gì cả — kể cả tên thư mục. Hub không đọc file của bạn và không điều khiển máy này.",
|
||||||
|
"ruleKey": "Key của tài khoản công ty nằm ở hub; máy này chỉ giữ một device token (90 ngày, thu hồi được).",
|
||||||
|
"ruleOffice": "Hub nằm trong mạng công ty: ở ngoài, session tài khoản công ty không mở được; tài khoản riêng vẫn dùng bình thường.",
|
||||||
|
"enrolledAs": "Đã đăng ký với {{hub}} bằng {{user}}",
|
||||||
|
"validUntil": "hiệu lực đến {{time}}",
|
||||||
|
"expired": "đã hết hạn",
|
||||||
|
"expiredHint": "Đăng nhập hub của máy này đã hết hạn — đăng ký lại.",
|
||||||
|
"sendNow": "Gửi ngay",
|
||||||
|
"logout": "Đăng xuất",
|
||||||
|
"logoutTitle": "Đăng xuất máy này khỏi hub?",
|
||||||
|
"logoutHint": "Device token bị thu hồi trên hub. Session tài khoản công ty không dùng được ở máy này cho đến khi đăng ký lại.",
|
||||||
|
"sentTitle": "Đã gửi lên hub",
|
||||||
|
"sentLine": "{{bytes}} · {{time}}",
|
||||||
|
"notSentYet": "chưa gửi",
|
||||||
|
"ended": "đã kết thúc",
|
||||||
|
"nothingSent": "Chưa có session tài khoản công ty nào.",
|
||||||
|
"sentToast": "Đã gửi {{bytes}} của {{sessions}} session",
|
||||||
|
"enrolledToast": "Máy này đã đăng ký với hub",
|
||||||
|
"hubAddress": "Địa chỉ hub",
|
||||||
|
"username": "Tên đăng nhập hub",
|
||||||
|
"password": "Mật khẩu hub",
|
||||||
|
"enroll": "Đăng ký máy này",
|
||||||
|
"cliHint": "Hoặc trong terminal: agenthub remote login <địa chỉ hub>",
|
||||||
|
"hubTitle": "Máy của bạn",
|
||||||
|
"hubDescription": "Các máy chạy AgentHub local bạn đã đăng ký với hub này, và các session tài khoản công ty của chúng.",
|
||||||
|
"noDevices": "Chưa có máy nào đăng ký.",
|
||||||
|
"revokedAt": "đã thu hồi {{time}}",
|
||||||
|
"lastSeen": "thấy lần cuối {{time}} từ {{ip}}",
|
||||||
|
"revoke": "Thu hồi",
|
||||||
|
"revokeTitle": "Thu hồi {{name}}?",
|
||||||
|
"revokeHint": "Device token ngừng hoạt động ngay; máy đó phải đăng ký lại.",
|
||||||
|
"revoked": "Đã thu hồi {{name}}",
|
||||||
|
"yourPassword": "Mật khẩu của bạn",
|
||||||
|
"sessions": "Session tài khoản công ty",
|
||||||
|
"unmonitored": "không mở từ AgentHub",
|
||||||
|
"sessionLine": "{{requests}} request · {{input}} vào · {{output}} ra · {{account}}",
|
||||||
|
"purged": "đã xoá hội thoại (quá hạn lưu)",
|
||||||
|
"whoRead": "Ai đã xem session của bạn",
|
||||||
|
"nobodyRead": "Chưa ai xem session của bạn."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,7 +409,8 @@
|
|||||||
"activeLabel": "Đang chạy",
|
"activeLabel": "Đang chạy",
|
||||||
"activeValue": "{{sessions}} session · {{panes}} pane",
|
"activeValue": "{{sessions}} session · {{panes}} pane",
|
||||||
"nowTitle": "Đang chạy",
|
"nowTitle": "Đang chạy",
|
||||||
"poolTheirs7d": "của họ 7 ngày {{cost}}"
|
"poolTheirs7d": "của họ 7 ngày {{cost}}",
|
||||||
|
"devicesTitle": "Máy cá nhân (AgentHub local)"
|
||||||
},
|
},
|
||||||
"activity": "Hoạt động",
|
"activity": "Hoạt động",
|
||||||
"spend": "Chi phí hôm nay",
|
"spend": "Chi phí hôm nay",
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* @file openInPreview.test.ts
|
||||||
|
* @description The one link rule: which URLs the Preview takes (http(s),
|
||||||
|
* never the dashboard's origin, `0.0.0.0` read as `localhost`), that a local
|
||||||
|
* link goes to the session panel only when one answers, and that a
|
||||||
|
* Ctrl/Cmd click keeps the browser's own tab.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
OPEN_IN_PREVIEW_EVENT,
|
||||||
|
isLoopbackUrl,
|
||||||
|
normalizePreviewUrl,
|
||||||
|
onLinkClick,
|
||||||
|
openInPreview,
|
||||||
|
type OpenInPreview,
|
||||||
|
} from "../openInPreview";
|
||||||
|
|
||||||
|
const SELF = "http://localhost:4820";
|
||||||
|
|
||||||
|
describe("normalizePreviewUrl", () => {
|
||||||
|
it("adds http:// to a bare host:port and reads 0.0.0.0 as localhost", () => {
|
||||||
|
expect(normalizePreviewUrl("localhost:3000", SELF)).toBe("http://localhost:3000/");
|
||||||
|
expect(normalizePreviewUrl("http://0.0.0.0:5173/a", SELF)).toBe("http://localhost:5173/a");
|
||||||
|
});
|
||||||
|
it("refuses the dashboard's own origin, other schemes and credentials", () => {
|
||||||
|
expect(normalizePreviewUrl("http://localhost:4820/settings", SELF)).toBeNull();
|
||||||
|
expect(normalizePreviewUrl("javascript:alert(1)", SELF)).toBeNull();
|
||||||
|
expect(normalizePreviewUrl("file:///etc/passwd", SELF)).toBeNull();
|
||||||
|
expect(normalizePreviewUrl("http://u:p@localhost:3000", SELF)).toBeNull();
|
||||||
|
expect(normalizePreviewUrl(" ", SELF)).toBeNull();
|
||||||
|
});
|
||||||
|
it("accepts an external https page", () => {
|
||||||
|
expect(normalizePreviewUrl("https://example.com/x", SELF)).toBe("https://example.com/x");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLoopbackUrl", () => {
|
||||||
|
it("is true only for this machine's hosts", () => {
|
||||||
|
expect(isLoopbackUrl("http://localhost:3000")).toBe(true);
|
||||||
|
expect(isLoopbackUrl("http://127.0.0.1:8080/x")).toBe(true);
|
||||||
|
expect(isLoopbackUrl("http://[::1]:3000")).toBe(true);
|
||||||
|
expect(isLoopbackUrl("https://github.com")).toBe(false);
|
||||||
|
expect(isLoopbackUrl("not a url")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openInPreview", () => {
|
||||||
|
let off: (() => void) | null = null;
|
||||||
|
afterEach(() => off?.());
|
||||||
|
const listen = (take: boolean) => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const fn = (e: Event) => {
|
||||||
|
const d = (e as CustomEvent<OpenInPreview>).detail;
|
||||||
|
seen.push(d.url);
|
||||||
|
if (take) d.handled = true;
|
||||||
|
};
|
||||||
|
window.addEventListener(OPEN_IN_PREVIEW_EVENT, fn);
|
||||||
|
off = () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, fn);
|
||||||
|
return seen;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("hands a local page to a listening panel", () => {
|
||||||
|
const seen = listen(true);
|
||||||
|
expect(openInPreview("http://localhost:3000/login")).toBe(true);
|
||||||
|
expect(seen).toEqual(["http://localhost:3000/login"]);
|
||||||
|
});
|
||||||
|
it("falls back when no panel takes it, and never sends an external page", () => {
|
||||||
|
const seen = listen(false);
|
||||||
|
expect(openInPreview("http://localhost:3000")).toBe(false);
|
||||||
|
expect(openInPreview("https://github.com/login")).toBe(false);
|
||||||
|
expect(seen).toEqual(["http://localhost:3000/"]);
|
||||||
|
});
|
||||||
|
it("leaves a Ctrl/Cmd click to the browser", () => {
|
||||||
|
listen(true);
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
const base = { ctrlKey: false, metaKey: false, shiftKey: false, button: 0, preventDefault };
|
||||||
|
onLinkClick({ ...base, ctrlKey: true }, "http://localhost:3000");
|
||||||
|
expect(preventDefault).not.toHaveBeenCalled();
|
||||||
|
onLinkClick(base, "http://localhost:3000");
|
||||||
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -432,6 +432,14 @@ describe("readRunBusy", () => {
|
|||||||
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
||||||
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
||||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||||
|
// Claude's spinner with the footer's hint gone (a narrow pane, a tip).
|
||||||
|
rows = ["✶ Skedaddling… (41s · ↓ 2.8k tokens · thought for 2s)", " ⏵⏵ auto mode on"];
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||||
|
rows = ["✻ Thinking… (1m 3s)"];
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||||
|
// The line a finished turn leaves is not.
|
||||||
|
rows = ["✻ Cooked for 1m 2s", "❯ "];
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||||
// Claude's permission footer is not a turn at work.
|
// Claude's permission footer is not a turn at work.
|
||||||
rows = ["Esc to cancel · Tab to amend"];
|
rows = ["Esc to cancel · Tab to amend"];
|
||||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* @file uploadStaged.test.ts
|
||||||
|
* @description The resumable File Explorer upload (`api.files.uploadStaged`):
|
||||||
|
* a file goes in parts to `/fs/upload-part` and is committed with
|
||||||
|
* `?staged=<id>`; a dashboard that goes away mid-way (network error, a
|
||||||
|
* proxy's bare 502) is waited out and the upload resumes from the byte the
|
||||||
|
* server reports; a refusal with our own error code is not retried.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { api, newUploadId } from "../api";
|
||||||
|
|
||||||
|
type Answer = { status: number; body?: unknown } | "neterr";
|
||||||
|
|
||||||
|
/** A fake XHR that answers each send with the next scripted answer. */
|
||||||
|
class FakeXhr {
|
||||||
|
static answers: ((url: string, size: number) => Answer)[] = [];
|
||||||
|
static sent: { url: string; size: number }[] = [];
|
||||||
|
status = 0;
|
||||||
|
responseText = "";
|
||||||
|
upload: { onprogress: ((e: { loaded: number }) => void) | null } = { onprogress: null };
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onabort: (() => void) | null = null;
|
||||||
|
private url = "";
|
||||||
|
open(_m: string, url: string) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
setRequestHeader() {}
|
||||||
|
abort() {
|
||||||
|
this.onabort?.();
|
||||||
|
}
|
||||||
|
send(body: Blob) {
|
||||||
|
FakeXhr.sent.push({ url: this.url, size: body.size });
|
||||||
|
const next = FakeXhr.answers.shift();
|
||||||
|
const a = next ? next(this.url, body.size) : { status: 500, body: { error: { code: "EX" } } };
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (a === "neterr") return this.onerror?.();
|
||||||
|
this.status = a.status;
|
||||||
|
this.responseText = a.body === undefined ? "" : JSON.stringify(a.body);
|
||||||
|
this.onload?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
const q = (url: string) => new URL(url, "http://x").searchParams;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
FakeXhr.answers = [];
|
||||||
|
FakeXhr.sent = [];
|
||||||
|
vi.stubGlobal("XMLHttpRequest", FakeXhr);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("api.files.uploadStaged", () => {
|
||||||
|
it("sends parts, waits out a restart, resumes from the server's count, then commits", async () => {
|
||||||
|
const file = new Blob([new Uint8Array(20 * MB)]);
|
||||||
|
const upload = newUploadId();
|
||||||
|
expect(upload).toMatch(/^[0-9a-f]{32}$/);
|
||||||
|
// part 1 ok; part 2 dies with the server; part 2 again after the restart;
|
||||||
|
// part 3; the commit's answer is lost once; then the stored answer.
|
||||||
|
FakeXhr.answers = [
|
||||||
|
() => ({ status: 200, body: { received: 8 * MB } }),
|
||||||
|
() => "neterr",
|
||||||
|
(url) => ({ status: 200, body: { received: Number(q(url).get("offset")) + 8 * MB } }),
|
||||||
|
() => ({ status: 200, body: { received: 20 * MB } }),
|
||||||
|
() => ({ status: 502 }),
|
||||||
|
() => ({ status: 201, body: { path: "big.bin" } }),
|
||||||
|
];
|
||||||
|
// While away: the status read says 10 MB arrived before the server died.
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ received: 10 * MB, total: 20 * MB, done: false }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const waits: boolean[] = [];
|
||||||
|
const done = api.files.uploadStaged(7, "big.bin", file, "fail", {
|
||||||
|
upload,
|
||||||
|
onWait: (w) => waits.push(w),
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(done).resolves.toEqual({ path: "big.bin" });
|
||||||
|
const parts = FakeXhr.sent.filter((s) => s.url.includes("/upload-part"));
|
||||||
|
expect(parts.map((p) => [q(p.url).get("offset"), p.size])).toEqual([
|
||||||
|
["0", 8 * MB],
|
||||||
|
[String(8 * MB), 8 * MB],
|
||||||
|
[String(10 * MB), 8 * MB],
|
||||||
|
[String(18 * MB), 2 * MB],
|
||||||
|
]);
|
||||||
|
for (const p of parts) expect(q(p.url).get("upload")).toBe(upload);
|
||||||
|
const commits = FakeXhr.sent.filter((s) => s.url.includes("/fs/upload?"));
|
||||||
|
expect(commits).toHaveLength(2);
|
||||||
|
expect(q(commits[0]!.url).get("staged")).toBe(upload);
|
||||||
|
expect(q(commits[0]!.url).get("path")).toBe("big.bin");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(waits).toEqual([true, false, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes on from the server's count on EOFFSET, and never retries our own refusal", async () => {
|
||||||
|
const file = new Blob([new Uint8Array(10)]);
|
||||||
|
FakeXhr.answers = [
|
||||||
|
() => ({ status: 409, body: { error: { code: "EOFFSET", message: "x", received: 10 } } }),
|
||||||
|
() => ({ status: 409, body: { error: { code: "EEXISTS", message: "taken", names: ["a"] } } }),
|
||||||
|
];
|
||||||
|
const done = api.files.uploadStaged(7, "a", file, "fail", { upload: newUploadId() });
|
||||||
|
const settled = expect(done).rejects.toMatchObject({ code: "EEXISTS", names: ["a"] });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await settled;
|
||||||
|
expect(FakeXhr.sent).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up once the dashboard has been away too long", async () => {
|
||||||
|
const file = new Blob([new Uint8Array(4)]);
|
||||||
|
FakeXhr.answers = Array.from({ length: 200 }, () => () => "neterr" as const);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const done = api.files.uploadStaged(7, "a", file, "fail", { upload: newUploadId() });
|
||||||
|
const settled = expect(done).rejects.toMatchObject({ code: "ENET" });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await settled;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* @file wheelReports.test.ts
|
||||||
|
* @description A touchpad scrolls 1:1 with the finger (one report per
|
||||||
|
* LINES_PER_REPORT rows, remainder carried), a wheel notch always sends one.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports";
|
||||||
|
|
||||||
|
const ROW = 20;
|
||||||
|
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
|
||||||
|
|
||||||
|
describe("wheelReports", () => {
|
||||||
|
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
|
||||||
|
const s: WheelState = { partial: 0 };
|
||||||
|
let sent = 0;
|
||||||
|
// 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
|
||||||
|
for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
|
||||||
|
expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / LINES_PER_REPORT));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the carried remainder when the swipe turns around", () => {
|
||||||
|
const s: WheelState = { partial: 0 };
|
||||||
|
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
|
||||||
|
let sent = 0;
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends at least one report for a wheel notch, in either delta mode", () => {
|
||||||
|
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: 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", () => {
|
||||||
|
expect(wheelReports({ partial: 0 }, px(0), ROW, 40)).toBe(0);
|
||||||
|
expect(wheelReports({ partial: 0 }, px(10), 0, 40)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+384
-26
@@ -1188,7 +1188,9 @@ const sendRaw = <T>(
|
|||||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
|
let body: {
|
||||||
|
error?: { code?: string; message?: string; names?: string[]; received?: number };
|
||||||
|
} = {};
|
||||||
try {
|
try {
|
||||||
body = JSON.parse(xhr.responseText);
|
body = JSON.parse(xhr.responseText);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1206,12 +1208,148 @@ const sendRaw = <T>(
|
|||||||
code: body.error?.code,
|
code: body.error?.code,
|
||||||
status: xhr.status,
|
status: xhr.status,
|
||||||
names: body.error?.names,
|
names: body.error?.names,
|
||||||
|
received: body.error?.received,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
xhr.send(payload);
|
xhr.send(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A batch body (`/fs/upload-batch`): a 4-byte length, the JSON file list,
|
||||||
|
* then each file's bytes. The Blob is only composed, never read into memory.
|
||||||
|
*/
|
||||||
|
function batchBody(files: { file: Blob; rel: string }[]): Blob {
|
||||||
|
const head = new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
files: files.map((f) => ({
|
||||||
|
path: f.rel,
|
||||||
|
size: f.file.size,
|
||||||
|
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const len = new Uint8Array(4);
|
||||||
|
new DataView(len.buffer).setUint32(0, head.length);
|
||||||
|
return new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One part of a resumable upload (the server takes at most 16 MB). */
|
||||||
|
const UPLOAD_PART_BYTES = 8 * 1024 * 1024;
|
||||||
|
/** How long an upload waits for the dashboard to come back (a restart, an update). */
|
||||||
|
const UPLOAD_WAIT_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
/** A fresh id for one resumable upload (also outside a secure context). */
|
||||||
|
export function newUploadId(): string {
|
||||||
|
const b = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(b);
|
||||||
|
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendErr = Error & { code?: string; status?: number; received?: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dashboard is away (restarting) rather than refusing: worth waiting for.
|
||||||
|
* A 5xx with none of our error codes is a proxy (nginx, Vite) answering for it.
|
||||||
|
*/
|
||||||
|
const isAway = (e: SendErr) =>
|
||||||
|
e.code === "ENET" || e.code === "EBUSY" || ((e.status ?? 0) >= 500 && !e.code);
|
||||||
|
|
||||||
|
const pause = (ms: number, signal?: AbortSignal) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(Object.assign(new Error("aborted"), { code: "EABORT" }));
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Callbacks of a resumable upload. */
|
||||||
|
export interface StagedSendOpts {
|
||||||
|
/** The upload's id: pass the SAME one to retry a commit (another `conflict`) without resending. */
|
||||||
|
upload: string;
|
||||||
|
onProgress?: (sent: number) => void;
|
||||||
|
/** true while the dashboard is away and the upload waits to resume. */
|
||||||
|
onWait?: (waiting: boolean) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send `payload` in parts to `place`'s staging (`/fs/upload-part`), then
|
||||||
|
* POST `commitUrl(id)` to write it. A dropped connection or a restarting
|
||||||
|
* dashboard (network error, 502/503/504) is waited out for up to
|
||||||
|
* UPLOAD_WAIT_MS, and the upload resumes from the byte the server has.
|
||||||
|
*/
|
||||||
|
async function sendStaged<T>(
|
||||||
|
place: FsPlace,
|
||||||
|
commitUrl: (id: string) => string,
|
||||||
|
payload: Blob,
|
||||||
|
{ upload, onProgress, onWait, signal }: StagedSendOpts
|
||||||
|
): Promise<T> {
|
||||||
|
const total = payload.size;
|
||||||
|
let received = 0;
|
||||||
|
let awaySince = 0;
|
||||||
|
let delay = 500;
|
||||||
|
const waitOut = async (e: SendErr) => {
|
||||||
|
if (!isAway(e)) throw e;
|
||||||
|
if (!awaySince) awaySince = Date.now();
|
||||||
|
if (Date.now() - awaySince > UPLOAD_WAIT_MS) throw e;
|
||||||
|
onWait?.(true);
|
||||||
|
await pause(delay, signal);
|
||||||
|
delay = Math.min(delay * 2, 5000);
|
||||||
|
};
|
||||||
|
const back = () => {
|
||||||
|
if (awaySince) onWait?.(false);
|
||||||
|
awaySince = 0;
|
||||||
|
delay = 500;
|
||||||
|
};
|
||||||
|
const partUrl = (q: Record<string, string>) =>
|
||||||
|
fsUrl(place, "/upload-part", new URLSearchParams({ upload, ...q }));
|
||||||
|
// An empty file is still one (empty) part: it makes the upload known.
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
const r = await sendRaw<{ received: number }>(
|
||||||
|
partUrl({ offset: String(received), total: String(total) }),
|
||||||
|
payload.slice(received, received + UPLOAD_PART_BYTES),
|
||||||
|
(n) => onProgress?.(received + n),
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
received = r.received;
|
||||||
|
back();
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as SendErr;
|
||||||
|
if (e.code === "EOFFSET" && typeof e.received === "number") {
|
||||||
|
// The server has more (or less) than we thought: go on from there.
|
||||||
|
received = e.received;
|
||||||
|
back();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await waitOut(e);
|
||||||
|
try {
|
||||||
|
const s = await request<{ received: number; done: boolean }>(partUrl({}));
|
||||||
|
received = s.received;
|
||||||
|
} catch {
|
||||||
|
/* still away: the next part asks again */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress?.(received);
|
||||||
|
} while (received < total);
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const r = await sendRaw<T>(commitUrl(upload), new Blob([]), undefined, signal);
|
||||||
|
back();
|
||||||
|
return r;
|
||||||
|
} catch (err) {
|
||||||
|
// A commit whose answer was lost is answered again, never written twice.
|
||||||
|
await waitOut(err as SendErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||||
export interface FsHome {
|
export interface FsHome {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1430,7 +1568,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||||
users: {
|
users: {
|
||||||
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
|
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
||||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||||
@@ -1720,6 +1858,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`. */
|
||||||
@@ -1740,6 +1880,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);
|
||||||
@@ -3300,6 +3441,28 @@ export const api = {
|
|||||||
onProgress,
|
onProgress,
|
||||||
signal
|
signal
|
||||||
),
|
),
|
||||||
|
/**
|
||||||
|
* {@link files.upload}, resumable: sent in parts and written once whole,
|
||||||
|
* so a dashboard restart mid-way only pauses it ({@link sendStaged}).
|
||||||
|
*/
|
||||||
|
uploadStaged: (
|
||||||
|
t: FsPlace,
|
||||||
|
path: string,
|
||||||
|
file: Blob,
|
||||||
|
conflict: FsConflict,
|
||||||
|
opts: StagedSendOpts
|
||||||
|
) =>
|
||||||
|
sendStaged<{ path: string; skipped?: boolean }>(
|
||||||
|
t,
|
||||||
|
(staged) => fsUrl(t, "/upload", new URLSearchParams({ path, conflict, staged })),
|
||||||
|
file,
|
||||||
|
opts
|
||||||
|
),
|
||||||
|
/** Forget a resumable upload's staged bytes (stopped, or given up). Never throws. */
|
||||||
|
dropUpload: (t: FsPlace, upload: string) =>
|
||||||
|
request<{ dropped: boolean }>(fsUrl(t, "/upload-part", new URLSearchParams({ upload })), {
|
||||||
|
method: "DELETE",
|
||||||
|
}).catch(() => null),
|
||||||
/**
|
/**
|
||||||
* Many small files in ONE request, under the folder `dir` (each `rel`
|
* Many small files in ONE request, under the folder `dir` (each `rel`
|
||||||
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
||||||
@@ -3312,25 +3475,27 @@ export const api = {
|
|||||||
conflict: FsConflict = "fail",
|
conflict: FsConflict = "fail",
|
||||||
onProgress?: (sent: number) => void,
|
onProgress?: (sent: number) => void,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
) => {
|
) =>
|
||||||
const head = new TextEncoder().encode(
|
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||||
JSON.stringify({
|
|
||||||
files: files.map((f) => ({
|
|
||||||
path: f.rel,
|
|
||||||
size: f.file.size,
|
|
||||||
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const len = new Uint8Array(4);
|
|
||||||
new DataView(len.buffer).setUint32(0, head.length);
|
|
||||||
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
|
||||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
batchBody(files),
|
||||||
onProgress,
|
onProgress,
|
||||||
signal
|
signal
|
||||||
);
|
),
|
||||||
},
|
/** {@link files.uploadBatch}, resumable like {@link files.uploadStaged}. */
|
||||||
|
uploadBatchStaged: (
|
||||||
|
t: FsPlace,
|
||||||
|
dir: string,
|
||||||
|
files: { file: Blob; rel: string }[],
|
||||||
|
conflict: FsConflict,
|
||||||
|
opts: StagedSendOpts
|
||||||
|
) =>
|
||||||
|
sendStaged<{ saved: number; skipped: number; paths: string[] }>(
|
||||||
|
t,
|
||||||
|
(staged) => fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict, staged })),
|
||||||
|
batchBody(files),
|
||||||
|
opts
|
||||||
|
),
|
||||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||||
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
||||||
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
||||||
@@ -3356,6 +3521,32 @@ export const api = {
|
|||||||
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
||||||
removeKeyById: (id: string) =>
|
removeKeyById: (id: string) =>
|
||||||
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||||
|
/**
|
||||||
|
* POST /api/me/git/ssh-key/import — a private key the user already has
|
||||||
|
* (the file's text); `sign` signs commits with it at once, `replace`
|
||||||
|
* over a signing key they set themselves. Only its public half returns.
|
||||||
|
*/
|
||||||
|
importKey: (body: { privateKey: string; name?: string; sign?: boolean; replace?: boolean }) =>
|
||||||
|
request<GitIdentity & { imported: GitSshKey | null; signError: ApiErrorBody | null }>(
|
||||||
|
"/me/git/ssh-key/import",
|
||||||
|
{ method: "POST", body: JSON.stringify(body) }
|
||||||
|
),
|
||||||
|
/**
|
||||||
|
* PUT /api/me/git/signing — the account-wide setting: sign every commit
|
||||||
|
* with key `key` (its id), or stop (null); `clearForeign` also takes out a
|
||||||
|
* signing setup the user made themselves (their key file stays).
|
||||||
|
*/
|
||||||
|
setSigning: (key: string | null, replace = false, clearForeign = false) =>
|
||||||
|
request<GitIdentity>("/me/git/signing", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(clearForeign ? { key, replace, clearForeign } : { key, replace }),
|
||||||
|
}),
|
||||||
|
/** PUT /api/me/git/signing/lane — sign ONE workspace's commits with `key`, or stop (null). */
|
||||||
|
setLaneSigning: (laneId: number, key: string | null) =>
|
||||||
|
request<GitIdentity>("/me/git/signing/lane", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ laneId, key }),
|
||||||
|
}),
|
||||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||||
setTerminalSsh: (on: boolean) =>
|
setTerminalSsh: (on: boolean) =>
|
||||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||||
@@ -3410,7 +3601,11 @@ export const api = {
|
|||||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||||
artifacts: {
|
artifacts: {
|
||||||
list: (
|
list: (
|
||||||
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
params: {
|
||||||
|
scope?: "mine" | "hub" | "withme" | "all" | "unowned";
|
||||||
|
kind?: ArtifactKind;
|
||||||
|
q?: string;
|
||||||
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.scope) qs.set("scope", params.scope);
|
if (params.scope) qs.set("scope", params.scope);
|
||||||
@@ -3420,6 +3615,17 @@ export const api = {
|
|||||||
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
|
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
|
||||||
},
|
},
|
||||||
settings: () => request<ArtifactSettings>("/artifacts/settings"),
|
settings: () => request<ArtifactSettings>("/artifacts/settings"),
|
||||||
|
setExportsEnabled: (exportsEnabled: boolean) =>
|
||||||
|
request<{ installsEnabled: boolean; exportsEnabled: boolean }>("/artifacts/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ exportsEnabled }),
|
||||||
|
}),
|
||||||
|
/** A page's viewable copy, taken now from the file it was published from (if still there). */
|
||||||
|
copy: (id: number) =>
|
||||||
|
request<{ item: Artifact }>(`/artifacts/${id}/copy`, { method: "POST", body: "{}" }),
|
||||||
|
/** Server mode: who the caller may share with — names only. */
|
||||||
|
people: () =>
|
||||||
|
request<{ people: { id: number; username: string; name: string }[] }>("/artifacts/people"),
|
||||||
setInstallsEnabled: (installsEnabled: boolean) =>
|
setInstallsEnabled: (installsEnabled: boolean) =>
|
||||||
request<{ installsEnabled: boolean }>("/artifacts/settings", {
|
request<{ installsEnabled: boolean }>("/artifacts/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -3506,6 +3712,8 @@ export const api = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
|
/** Server mode: the user ids it is shared with by name (replaces the list). */
|
||||||
|
sharedWith?: number[];
|
||||||
/** plugin only: `name@marketplace`. */
|
/** plugin only: `name@marketplace`. */
|
||||||
source?: string;
|
source?: string;
|
||||||
/** plugin only: the marketplace source. */
|
/** plugin only: the marketplace source. */
|
||||||
@@ -3591,6 +3799,14 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
}),
|
}),
|
||||||
|
/** GET /api/ai-tools/member-policy - what members' Claude may run (server mode, S3). */
|
||||||
|
memberPolicy: () => request<MemberPolicyState>("/ai-tools/member-policy"),
|
||||||
|
/** PUT /api/ai-tools/member-policy - an admin's switch. */
|
||||||
|
setMemberPolicy: (allowTests: boolean) =>
|
||||||
|
request<MemberPolicyState>("/ai-tools/member-policy", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ allowTests }),
|
||||||
|
}),
|
||||||
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
||||||
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
||||||
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
||||||
@@ -3647,6 +3863,55 @@ export const api = {
|
|||||||
*
|
*
|
||||||
* No response here ever carries an API key — a saved profile reports only
|
* No response here ever carries an API key — a saved profile reports only
|
||||||
* `hasKey`. */
|
* `hasKey`. */
|
||||||
|
/** A LOCAL AgentHub's link to the company hub (server/routes/remote-client.js). */
|
||||||
|
remoteClient: {
|
||||||
|
/** GET /api/remote-client - enrollment and what was sent; never the token. */
|
||||||
|
status: () => request<RemoteClientStatus>("/remote-client"),
|
||||||
|
/** POST /api/remote-client/login - the password goes to the hub once, not kept. */
|
||||||
|
login: (body: { hub: string; username: string; password: string; deviceName?: string }) =>
|
||||||
|
request<RemoteClientStatus>("/remote-client/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
/** POST /api/remote-client/logout - revoke on the hub, forget the token here. */
|
||||||
|
logout: () =>
|
||||||
|
request<{ ok: true; wasEnrolled: boolean; revokedOnHub?: boolean }>("/remote-client/logout", {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
/** POST /api/remote-client/upload - send the Company-account sessions' new lines now. */
|
||||||
|
upload: () =>
|
||||||
|
request<{
|
||||||
|
sessions: number;
|
||||||
|
bytes: number;
|
||||||
|
errors: { sessionId: string; message: string }[];
|
||||||
|
}>("/remote-client/upload", { method: "POST" }),
|
||||||
|
},
|
||||||
|
/** The hub's remote-client views (server mode, server/hub/). */
|
||||||
|
remoteHub: {
|
||||||
|
/** GET /api/remote/devices - your machines; staff may name `user`. */
|
||||||
|
devices: (user?: number) =>
|
||||||
|
request<{ devices: RemoteDevice[] }>(`/remote/devices${user ? `?user=${user}` : ""}`),
|
||||||
|
/** POST /api/remote/devices/:id/revoke - your own password again. */
|
||||||
|
revoke: (id: number, password: string) =>
|
||||||
|
request<{ ok: true; device: RemoteDevice }>(`/remote/devices/${id}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}),
|
||||||
|
/** GET /api/remote/sessions - Company-account sessions (device › folder). */
|
||||||
|
sessions: (user?: number) =>
|
||||||
|
request<{ sessions: RemoteSessionRow[] }>(`/remote/sessions${user ? `?user=${user}` : ""}`),
|
||||||
|
/** GET /api/remote/audit - who opened your sessions. */
|
||||||
|
audit: () =>
|
||||||
|
request<{
|
||||||
|
entries: {
|
||||||
|
id: number;
|
||||||
|
at: string;
|
||||||
|
sessionId: string;
|
||||||
|
reader: string | null;
|
||||||
|
readerDisplay: string | null;
|
||||||
|
}[];
|
||||||
|
}>("/remote/audit"),
|
||||||
|
},
|
||||||
aiProxies: {
|
aiProxies: {
|
||||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||||
@@ -5023,6 +5288,10 @@ export interface Artifact {
|
|||||||
size: number | null;
|
size: number | null;
|
||||||
shared: boolean;
|
shared: boolean;
|
||||||
sharedAt: string | null;
|
sharedAt: string | null;
|
||||||
|
/** Server mode: the people it is shared with by name (only its owner or an admin gets the list). */
|
||||||
|
sharedWith?: { id: number; name: string }[];
|
||||||
|
/** Server mode: shared with the caller by name. */
|
||||||
|
sharedWithMe?: boolean;
|
||||||
installs: number;
|
installs: number;
|
||||||
/** False once its owner removed it from their library while shared: Hub only. */
|
/** False once its owner removed it from their library while shared: Hub only. */
|
||||||
inLibrary: boolean;
|
inLibrary: boolean;
|
||||||
@@ -5083,6 +5352,13 @@ export interface ArtifactCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
||||||
|
export interface MemberPolicyState {
|
||||||
|
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||||
|
available: boolean;
|
||||||
|
/** May members' Claude run test runners and container commands? Off by default. */
|
||||||
|
allowTests: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OmpSharingState {
|
export interface OmpSharingState {
|
||||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -5096,6 +5372,8 @@ export interface ArtifactSettings {
|
|||||||
serverMode: boolean;
|
serverMode: boolean;
|
||||||
canShare: boolean;
|
canShare: boolean;
|
||||||
installsEnabled: boolean;
|
installsEnabled: boolean;
|
||||||
|
/** Claude panes are asked to save a viewable copy of a Doc / Slides / Design they publish. */
|
||||||
|
exportsEnabled?: boolean;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5232,6 +5510,21 @@ export interface GitCredential {
|
|||||||
hasToken: true;
|
hasToken: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One SSH key of `GET /api/me/git` — its public half only. */
|
||||||
|
export interface GitSshKey {
|
||||||
|
id?: string;
|
||||||
|
type?: "ed25519" | "rsa";
|
||||||
|
name?: string | null;
|
||||||
|
publicKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An API error's `{code, message}`. */
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
||||||
export interface GitIdentity {
|
export interface GitIdentity {
|
||||||
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
||||||
@@ -5249,13 +5542,19 @@ export interface GitIdentity {
|
|||||||
* Every key the account holds: the default ED25519 and RSA first, then the
|
* Every key the account holds: the default ED25519 and RSA first, then the
|
||||||
* named ones (`name`; `id` = its file name); absent from an older server.
|
* named ones (`name`; `id` = its file name); absent from an older server.
|
||||||
*/
|
*/
|
||||||
sshKeys?: {
|
sshKeys?: GitSshKey[];
|
||||||
id?: string;
|
/**
|
||||||
type?: "ed25519" | "rsa";
|
* Commit signing (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign`
|
||||||
name?: string | null;
|
* in `~/.gitconfig`): `on` with one of these keys (`key` = its id), `off`,
|
||||||
publicKey: string;
|
* or `foreign` — a signing key the user set themselves (`path`). Absent
|
||||||
fingerprint: string;
|
* from an older server.
|
||||||
}[];
|
*/
|
||||||
|
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
|
||||||
|
/**
|
||||||
|
* The workspaces whose commits are signed (an `includeIf "gitdir:…"` each):
|
||||||
|
* `key` = the key's id, null when its file is gone. Absent from an older server.
|
||||||
|
*/
|
||||||
|
laneSigning?: { laneId: number; key: string | null }[];
|
||||||
/** The terminal switch (shared by both keys); null without a key. */
|
/** The terminal switch (shared by both keys); null without a key. */
|
||||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||||
/**
|
/**
|
||||||
@@ -5387,6 +5686,9 @@ export interface AiProxy {
|
|||||||
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** The remote client's Company account (`agenthub.company`) — the hub's gateway,
|
||||||
|
* added by the client on an enrolled machine; never a saved profile. */
|
||||||
|
company?: boolean;
|
||||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
/** "shared": the host's (an admin's, in server mode), usable by everyone;
|
/** "shared": the host's (an admin's, in server mode), usable by everyone;
|
||||||
@@ -6578,3 +6880,59 @@ export interface PetList {
|
|||||||
featured: FeaturedPet[];
|
featured: FeaturedPet[];
|
||||||
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
|
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An enrolled machine on the hub (server/hub/devices.js) — never its token. */
|
||||||
|
export interface RemoteDevice {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
lastIp: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Company-account session a device registered (server/hub/remote-sessions.js). */
|
||||||
|
export interface RemoteSessionRow {
|
||||||
|
sessionId: string;
|
||||||
|
deviceId: number;
|
||||||
|
deviceName: string;
|
||||||
|
userId: number;
|
||||||
|
cwd: string;
|
||||||
|
tool: string;
|
||||||
|
account: string | null;
|
||||||
|
registeredAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
lastRequestAt: string | null;
|
||||||
|
requests: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
lastUploadAt: string | null;
|
||||||
|
purgedAt: string | null;
|
||||||
|
unmonitored: number;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/remote-client — this local machine's link to the hub. */
|
||||||
|
export interface RemoteClientStatus {
|
||||||
|
enrolled: boolean;
|
||||||
|
expired?: boolean;
|
||||||
|
hub?: string;
|
||||||
|
user?: { id: number; username: string; role: string } | null;
|
||||||
|
device?: { id: number; name: string; expiresAt: string } | null;
|
||||||
|
enrolledAt?: string | null;
|
||||||
|
retainDays?: number | null;
|
||||||
|
uploads?: {
|
||||||
|
sessionId: string;
|
||||||
|
bytes: number;
|
||||||
|
lastSentAt: string | null;
|
||||||
|
ended: number;
|
||||||
|
title: string | null;
|
||||||
|
cwd: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @file artifactFolders — the Artifacts page's user-made folders ("Slides",
|
||||||
|
* "Client A", …): their names and order, and which folder each artifact sits
|
||||||
|
* in. One JSON value under `agenthub.artifacts.folders`, an account preference
|
||||||
|
* key (`lib/userPrefs.ts`, `server/routes/me.js`), so the folders are kept per
|
||||||
|
* ACCOUNT on the server and follow it to any browser. A viewer's arrangement,
|
||||||
|
* not an artifact fact: nothing about an artifact changes, other users never
|
||||||
|
* see these folders, and deleting a folder only takes its artifacts out of it.
|
||||||
|
* Same store shape as `workspaceGroups.ts`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { PREFS_EVENT } from "./workspaceGroups";
|
||||||
|
|
||||||
|
export const ARTIFACT_FOLDERS_KEY = "agenthub.artifacts.folders";
|
||||||
|
export const MAX_FOLDER_NAME = 60;
|
||||||
|
|
||||||
|
export interface ArtifactFolder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactFolders {
|
||||||
|
/** In display order. */
|
||||||
|
folders: ArtifactFolder[];
|
||||||
|
/** Artifact id → folder id. An artifact absent here is in no folder. */
|
||||||
|
of: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: ArtifactFolders = { folders: [], of: {} };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let cache: { raw: string | null; value: ArtifactFolders } = { raw: null, value: EMPTY };
|
||||||
|
|
||||||
|
function readRaw(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(ARTIFACT_FOLDERS_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(raw: string | null): ArtifactFolders {
|
||||||
|
if (!raw) return EMPTY;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as Partial<ArtifactFolders>;
|
||||||
|
const folders = (Array.isArray(v.folders) ? v.folders : []).filter(
|
||||||
|
(f): f is ArtifactFolder =>
|
||||||
|
!!f && typeof f.id === "string" && !!f.id && typeof f.name === "string"
|
||||||
|
);
|
||||||
|
const ids = new Set(folders.map((f) => f.id));
|
||||||
|
const of: Record<string, string> = {};
|
||||||
|
if (v.of && typeof v.of === "object") {
|
||||||
|
for (const [item, fid] of Object.entries(v.of)) {
|
||||||
|
if (typeof fid === "string" && ids.has(fid)) of[item] = fid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { folders, of };
|
||||||
|
} catch {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readArtifactFolders(): ArtifactFolders {
|
||||||
|
const raw = readRaw();
|
||||||
|
if (raw === cache.raw) return cache.value;
|
||||||
|
cache = { raw, value: parse(raw) };
|
||||||
|
return cache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(next: ArtifactFolders): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ARTIFACT_FOLDERS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* disabled storage: the folders live until the page closes */
|
||||||
|
cache = { raw: JSON.stringify(next), value: next };
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(fn: (cur: ArtifactFolders) => ArtifactFolders): void {
|
||||||
|
write(fn(readArtifactFolders()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = (name: string) => name.trim().slice(0, MAX_FOLDER_NAME);
|
||||||
|
|
||||||
|
/** A new folder at the end, holding `itemIds` (moved out of their old folder). Returns its id, or null for a blank name. */
|
||||||
|
export function createFolder(name: string, itemIds: number[] = []): string | null {
|
||||||
|
const clean = cleanName(name);
|
||||||
|
if (!clean) return null;
|
||||||
|
const id = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
update((cur) => {
|
||||||
|
const of = { ...cur.of };
|
||||||
|
for (const item of itemIds) of[item] = id;
|
||||||
|
return { folders: [...cur.folders, { id, name: clean }], of };
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename; a blank name keeps the old one. */
|
||||||
|
export function renameFolder(id: string, name: string): void {
|
||||||
|
const clean = cleanName(name);
|
||||||
|
if (!clean) return;
|
||||||
|
update((cur) => ({
|
||||||
|
...cur,
|
||||||
|
folders: cur.folders.map((f) => (f.id === id ? { ...f, name: clean } : f)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the folder; its artifacts are only taken out of it. */
|
||||||
|
export function deleteFolder(id: string): void {
|
||||||
|
update((cur) => ({
|
||||||
|
folders: cur.folders.filter((f) => f.id !== id),
|
||||||
|
of: Object.fromEntries(Object.entries(cur.of).filter(([, fid]) => fid !== id)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put an artifact in a folder, or (null) take it out of every folder. */
|
||||||
|
export function moveToFolder(itemId: number, folderId: string | null): void {
|
||||||
|
update((cur) => {
|
||||||
|
const of = { ...cur.of };
|
||||||
|
if (folderId && cur.folders.some((f) => f.id === folderId)) of[itemId] = folderId;
|
||||||
|
else delete of[itemId];
|
||||||
|
return { ...cur, of };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder an artifact is in, or null. */
|
||||||
|
export function folderOf(value: ArtifactFolders, itemId: number): ArtifactFolder | null {
|
||||||
|
const fid = value.of[itemId];
|
||||||
|
return (fid && value.folders.find((f) => f.id === fid)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(l: () => void) {
|
||||||
|
listeners.add(l);
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === ARTIFACT_FOLDERS_KEY || e.key === null) l();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
window.addEventListener(PREFS_EVENT, l);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(l);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
window.removeEventListener(PREFS_EVENT, l);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArtifactFolders(): ArtifactFolders {
|
||||||
|
return useSyncExternalStore(subscribe, readArtifactFolders);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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];
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* @file openInPreview.ts — the ONE rule every link in the dashboard follows
|
||||||
|
* (a chat's markdown link or bare URL, a terminal's link): a page served by
|
||||||
|
* THIS machine (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) opens in the
|
||||||
|
* session view's Preview tab, everything else in a browser tab as before.
|
||||||
|
* The request travels as a window event (like `openInChanges.ts`); the
|
||||||
|
* Workspace page answers it only while it shows a session panel, so with no
|
||||||
|
* listener — another page, or a remote dashboard where "localhost" is the
|
||||||
|
* viewer's own machine — the link falls back to a browser tab. Also holds the
|
||||||
|
* URL checks the Preview tab itself uses.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const OPEN_IN_PREVIEW_EVENT = "agenthub:open-in-preview";
|
||||||
|
|
||||||
|
/** The event's detail. `handled` is set by the listener, read by the sender. */
|
||||||
|
export type OpenInPreview = { url: string; handled: boolean };
|
||||||
|
|
||||||
|
const LOOPBACK = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
|
||||||
|
|
||||||
|
/** A URL the Preview may show: http(s) only, `0.0.0.0` read as `localhost`
|
||||||
|
* (a dev server prints it, a browser cannot open it), never the dashboard's
|
||||||
|
* own origin (the frame would then BE the dashboard, cookies and API
|
||||||
|
* included). A bare `localhost:3000` gets `http://`. Null when refused. */
|
||||||
|
export function normalizePreviewUrl(raw: string, self = window.location.origin): string | null {
|
||||||
|
let text = raw.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `http://${text}`;
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
if (u.username || u.password) return null;
|
||||||
|
if (u.hostname === "0.0.0.0") u.hostname = "localhost";
|
||||||
|
if (u.origin === self) return null;
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for a page served by the machine the BROWSER runs on. */
|
||||||
|
export function isLoopbackUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.has(u.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboard itself is opened from this machine: only then does a
|
||||||
|
* `localhost` link name the same machine the agents run on. */
|
||||||
|
export function dashboardIsLocal(hostname = window.location.hostname): boolean {
|
||||||
|
return LOOPBACK.has(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the open session panel to show `url`. False when nothing took it
|
||||||
|
* (not a local page, a remote dashboard, no session panel). */
|
||||||
|
export function openInPreview(url: string): boolean {
|
||||||
|
if (!isLoopbackUrl(url) || !dashboardIsLocal()) return false;
|
||||||
|
const target = normalizePreviewUrl(url);
|
||||||
|
if (!target) return false;
|
||||||
|
const detail: OpenInPreview = { url: target, handled: false };
|
||||||
|
window.dispatchEvent(new CustomEvent(OPEN_IN_PREVIEW_EVENT, { detail }));
|
||||||
|
return detail.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A link click: Preview when the rule says so, else a browser tab. A
|
||||||
|
* Ctrl/Cmd/Shift/middle click always keeps the browser's own behaviour. */
|
||||||
|
export function onLinkClick(
|
||||||
|
e: {
|
||||||
|
ctrlKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
button: number;
|
||||||
|
preventDefault: () => void;
|
||||||
|
},
|
||||||
|
url: string
|
||||||
|
): void {
|
||||||
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
|
||||||
|
if (openInPreview(url)) e.preventDefault();
|
||||||
|
}
|
||||||
@@ -368,10 +368,15 @@ function typeRow(options: PaneMenuOption[]): number {
|
|||||||
/** The status line a tool's TUI shows only while a turn runs, read off its
|
/** The status line a tool's TUI shows only while a turn runs, read off its
|
||||||
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
|
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
|
||||||
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
|
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
|
||||||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
|
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it.
|
||||||
* tool missing here (omp, Cursor) has no line we know. */
|
* Claude ≥ 2.1.3xx keeps `esc to interrupt` only in its footer, which a
|
||||||
|
* narrow pane, a tip or a notice replaces, so its spinner line counts too:
|
||||||
|
* a verb, an ellipsis and the turn's time in parentheses
|
||||||
|
* (`✶ Skedaddling… (41s · ↓ 2.8k tokens)`) — the line a finished turn leaves
|
||||||
|
* (`✻ Cooked for 1m 2s`) has neither. A tool missing here (omp, Cursor) has
|
||||||
|
* no line we know. */
|
||||||
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
|
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
|
||||||
claude: /\besc to interrupt\b/i,
|
claude: /\besc to interrupt\b|…\s*\(\s*\d+[smh]\b/i,
|
||||||
codex: /\besc to interrupt\b/i,
|
codex: /\besc to interrupt\b/i,
|
||||||
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
|
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-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
|
||||||
@@ -1971,6 +1976,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 ─────
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
* kept in localStorage (refreshed every ~2 s), and the next page load shows
|
* kept in localStorage (refreshed every ~2 s), and the next page load shows
|
||||||
* a record that stopped being refreshed as an interrupted upload until it is
|
* a record that stopped being refreshed as an interrupted upload until it is
|
||||||
* dismissed. A fresh record belongs to another open tab and is left alone.
|
* dismissed. A fresh record belongs to another open tab and is left alone.
|
||||||
|
* A dashboard RESTART is not an interruption: the upload is sent in parts
|
||||||
|
* (`api.files.uploadStaged`) and the job shows `waiting` until it resumes.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ export interface UploadJob {
|
|||||||
sent: number;
|
sent: number;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
state: "running" | "stopping" | "interrupted";
|
state: "running" | "stopping" | "interrupted";
|
||||||
|
/** The dashboard is away (a restart): the upload waits and then resumes. */
|
||||||
|
waiting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stored = UploadJob & { beat: number };
|
type Stored = UploadJob & { beat: number };
|
||||||
@@ -115,7 +119,8 @@ export function beginUpload(init: { folder: string; total: number; bytes: number
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent">>) => set(id, patch),
|
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent" | "waiting">>) =>
|
||||||
|
set(id, patch),
|
||||||
end: () => {
|
end: () => {
|
||||||
controllers.delete(id);
|
controllers.delete(id);
|
||||||
uploads = uploads.filter((u) => u.id !== id);
|
uploads = uploads.filter((u) => u.id !== id);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* @file wheelReports.ts — turns a browser wheel event into the number of SGR
|
||||||
|
* wheel reports a terminal pane gets, in proportion to how far the finger or
|
||||||
|
* the wheel moved, the way a native terminal does.
|
||||||
|
*
|
||||||
|
* xterm.js sends ONE report per wheel event whatever its size, and damps a
|
||||||
|
* touchpad's pixel deltas ×0.3 before it counts even that one — a touchpad
|
||||||
|
* 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
|
||||||
|
* 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. `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>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
|
||||||
|
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). */
|
||||||
|
const NOTCH_PX = 50;
|
||||||
|
|
||||||
|
const DOM_DELTA_PIXEL = 0;
|
||||||
|
const DOM_DELTA_LINE = 1;
|
||||||
|
const DOM_DELTA_PAGE = 2;
|
||||||
|
|
||||||
|
export interface WheelState {
|
||||||
|
/** Touchpad travel not yet sent, in rows (signed). */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed report count for `ev` — negative scrolls up. `rowPx` is one row's
|
||||||
|
* height on screen (CSS scale included), `rows` the terminal's height.
|
||||||
|
*/
|
||||||
|
export function wheelReports(
|
||||||
|
state: WheelState,
|
||||||
|
ev: Pick<WheelEvent, "deltaY" | "deltaMode">,
|
||||||
|
rowPx: number,
|
||||||
|
rows: number
|
||||||
|
): number {
|
||||||
|
if (!ev.deltaY || !(rowPx > 0)) return 0;
|
||||||
|
const sign = ev.deltaY < 0 ? -1 : 1;
|
||||||
|
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
|
||||||
|
// Touchpad: carry the remainder, so slow swipes still add up.
|
||||||
|
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
|
||||||
|
state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED;
|
||||||
|
const n = Math.trunc(state.partial / LINES_PER_REPORT);
|
||||||
|
state.partial -= n * LINES_PER_REPORT;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
// A notch (or a fast flick): at least one report, more for a long one.
|
||||||
|
state.partial = 0;
|
||||||
|
if (state.notchDir !== sign) state.notch = 0; // turned around
|
||||||
|
state.notchDir = sign;
|
||||||
|
const lines =
|
||||||
|
ev.deltaMode === DOM_DELTA_LINE
|
||||||
|
? ev.deltaY
|
||||||
|
: ev.deltaMode === DOM_DELTA_PAGE
|
||||||
|
? ev.deltaY * rows
|
||||||
|
: ev.deltaY / rowPx;
|
||||||
|
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,10 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
|||||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||||
|
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||||
|
import { CompanyHubSettings } from "../components/settings/CompanyHubSettings";
|
||||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||||
@@ -317,6 +319,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
<ArtifactsHubSettings />
|
<ArtifactsHubSettings />
|
||||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||||
<OmpSharingSettings />
|
<OmpSharingSettings />
|
||||||
|
{/* Members' test runs on this box: server mode + S3, an admin's. */}
|
||||||
|
<MemberPolicySettings />
|
||||||
|
{/* The company hub: enroll this machine (local) / your machines (hub). */}
|
||||||
|
<CompanyHubSettings />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
|||||||
import { FileExplorer } from "../components/lanes/FileExplorer";
|
import { FileExplorer } from "../components/lanes/FileExplorer";
|
||||||
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
||||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||||
|
import { OPEN_IN_PREVIEW_EVENT, type OpenInPreview } from "../lib/openInPreview";
|
||||||
|
import { UrlPreview } from "../components/lanes/UrlPreview";
|
||||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||||
@@ -148,6 +150,8 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
|||||||
|
|
||||||
/** localStorage key for the Changes panel's open/closed state. */
|
/** localStorage key for the Changes panel's open/closed state. */
|
||||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||||
|
/** The session panel's tab — Changes or Preview. A viewer preference. */
|
||||||
|
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||||
/** localStorage key for how wide the Changes panel is. */
|
/** localStorage key for how wide the Changes panel is. */
|
||||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||||
/** localStorage key for the last width the user dragged the panel to — what
|
/** localStorage key for the last width the user dragged the panel to — what
|
||||||
@@ -799,6 +803,25 @@ export function Workspace() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// The panel's two tabs: Changes (git + files) and Preview (a web page).
|
||||||
|
// Both stay mounted once shown (CSS), so an unsaved edit or a loaded page
|
||||||
|
// survives a switch.
|
||||||
|
const [panelTab, setPanelTab] = useState<"changes" | "preview">(() => {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(PANEL_TAB_KEY) === "preview" ? "preview" : "changes";
|
||||||
|
} catch {
|
||||||
|
return "changes";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [previewShown, setPreviewShown] = useState(panelTab === "preview");
|
||||||
|
useEffect(() => {
|
||||||
|
if (panelTab === "preview") setPreviewShown(true);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||||
|
} catch {
|
||||||
|
/* disabled storage: the tab just forgets between visits */
|
||||||
|
}
|
||||||
|
}, [panelTab]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
|
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
|
||||||
@@ -1050,6 +1073,22 @@ export function Workspace() {
|
|||||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||||
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||||
}, []);
|
}, []);
|
||||||
|
// A link to a local page asks for it (`openInPreview`): taken only while a
|
||||||
|
// session panel is on screen, so elsewhere the link opens a browser tab.
|
||||||
|
const [previewRequest, setPreviewRequest] = useState<{ url: string; nonce: number } | null>(null);
|
||||||
|
const previewHostRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpen = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<OpenInPreview>).detail;
|
||||||
|
if (!previewHostRef.current) return;
|
||||||
|
detail.handled = true;
|
||||||
|
setChangesOpen(true);
|
||||||
|
setPanelTab("preview");
|
||||||
|
setPreviewRequest((r) => ({ url: detail.url, nonce: (r?.nonce ?? 0) + 1 }));
|
||||||
|
};
|
||||||
|
window.addEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
|
||||||
|
return () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
|
||||||
|
}, []);
|
||||||
// Asked once: a panel reopened or moved to another tree must not replay it.
|
// Asked once: a panel reopened or moved to another tree must not replay it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!changesOpen) setChangesRequest(null);
|
if (!changesOpen) setChangesRequest(null);
|
||||||
@@ -1230,6 +1269,7 @@ export function Workspace() {
|
|||||||
// The session view (console or replay) fills the page edge to edge, like an
|
// The session view (console or replay) fills the page edge to edge, like an
|
||||||
// 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;
|
||||||
const viewportLocked =
|
const viewportLocked =
|
||||||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||||
|
|
||||||
@@ -1567,9 +1607,25 @@ export function Workspace() {
|
|||||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
>
|
>
|
||||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
<div role="tablist" className="flex items-center gap-0.5">
|
||||||
{tLanes("panel.changes")}
|
{(["changes", "preview"] as const).map((tab) => (
|
||||||
</span>
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
data-testid={`panel-tab-${tab}`}
|
||||||
|
aria-selected={panelTab === tab}
|
||||||
|
onClick={() => setPanelTab(tab)}
|
||||||
|
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
panelTab === tab
|
||||||
|
? "bg-surface-4 text-fg-primary"
|
||||||
|
: "text-fg-muted hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tLanes(tab === "changes" ? "panel.changes" : "panel.preview")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{/* The opened session's analysis page — a page of its own. */}
|
{/* The opened session's analysis page — a page of its own. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1652,7 +1708,22 @@ export function Workspace() {
|
|||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
{previewShown && (
|
||||||
|
<div
|
||||||
|
className={panelTab === "preview" ? "flex min-h-0 flex-1 flex-col" : "hidden"}
|
||||||
|
>
|
||||||
|
<UrlPreview
|
||||||
|
laneId={currentLane.id}
|
||||||
|
ports={currentLane.ports ?? {}}
|
||||||
|
request={previewRequest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
panelTab === "changes" ? "flex min-h-0 flex-1 flex-col px-3 pt-2" : "hidden"
|
||||||
|
}
|
||||||
|
>
|
||||||
{panelAt &&
|
{panelAt &&
|
||||||
[
|
[
|
||||||
...(showRoot
|
...(showRoot
|
||||||
|
|||||||
@@ -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: [] })),
|
||||||
|
|||||||
@@ -1053,7 +1053,28 @@ describe("split panes", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await user.click(screen.getByTestId("pane-split-right-1"));
|
await user.click(screen.getByTestId("pane-split-right-1"));
|
||||||
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
|
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
|
||||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||||
|
slot: "auto",
|
||||||
|
prompt: "",
|
||||||
|
permissionMode: "auto",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a new AI pane starts in the account's Session defaults (bypass included)", async () => {
|
||||||
|
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||||
|
lanesToReturn = lanesToReturn.map((l) => ({ ...l, tool: "claude" }));
|
||||||
|
withRuns([live(0), live(1)]);
|
||||||
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
|
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByTestId("pane-split-right-1"));
|
||||||
|
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
|
||||||
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||||
|
slot: "auto",
|
||||||
|
prompt: "",
|
||||||
|
permissionMode: "bypassPermissions",
|
||||||
|
});
|
||||||
|
localStorage.removeItem("agenthub.permissionMode");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
it("with one pane, the split menus sit beside Tools and split it", 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.52 (rebuild client to match)
|
UI build v2.8.73 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+63
-15
File diff suppressed because one or more lines are too long
+5
-2
@@ -416,6 +416,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). A pane drag (a pane's header, or a one-pane tab) held over another tab for 0.6 s opens that tab (`SPRING_MS`), so the pane can be dropped on any edge of that tab's panes instead of the fixed join order; on a bar wider than the screen, dragging near either end scrolls it. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named `<workspace> · <session>`, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=<section>` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.
|
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). A pane drag (a pane's header, or a one-pane tab) held over another tab for 0.6 s opens that tab (`SPRING_MS`), so the pane can be dropped on any edge of that tab's panes instead of the fixed join order; on a bar wider than the screen, dragging near either end scrolls it. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named `<workspace> · <session>`, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=<section>` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.
|
||||||
|
|
||||||
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two (a session from another tab takes the pane's place and that one moves to a tab of its own) —; while the drag lasts the moved pane is dimmed, the drag shows a small chip with its name, and the drop preview says what will happen (*Place on the left*, *Swap places*…, `dropAction`), its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
|
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two (a session from another tab takes the pane's place and that one moves to a tab of its own) —; while the drag lasts the moved pane is dimmed, the drag shows a small chip with its name, and the drop preview says what will happen (*Place on the left*, *Swap places*…, `dropAction`), its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
|
||||||
|
- **Preview** — the session view's right panel has two tabs, **Changes** and **Preview** (`client/src/components/lanes/UrlPreview.tsx`). Preview shows a web page by address — before one is open, the workspace's bound ports are offered as `localhost:<port>` chips — with Reload and **Open in a browser tab**; the last address is remembered per workspace in this browser (`agenthub.preview.url.<id>`). Every link follows one rule (`client/src/lib/openInPreview.ts`): a chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens in Preview while a session view is open and the dashboard itself is opened on this machine; any other link, and any Ctrl/Cmd click, opens a browser tab. The page runs on its own origin (the dashboard's own is refused) with no top navigation. A site that forbids embedding — YouTube, Google, most login pages — shows blank or a broken-page icon; any page not on this machine gets a strip under the address bar saying so, with **Open tab** beside it.
|
||||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||||
|
|
||||||
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
||||||
@@ -530,7 +531,7 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
|||||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||||
| **Branch** → **Switch branch** | `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
| **Branch** → **Switch branch** | the list is a combobox: it opens with a **Search branches…** box focused, typing narrows the local and remote branches to names containing the text, ↑/↓ move, Enter picks, Esc closes (#88 — every `Select` longer than 8 options gets the same search box); `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||||
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
||||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||||
@@ -551,7 +552,9 @@ you made in a terminal is never shown or touched. **Shelve (n)** beside
|
|||||||
ones included) with an optional one-line note; **Shelve and switch** does the
|
ones included) with an optional one-line note; **Shelve and switch** does the
|
||||||
same for every change before a switch, and puts the shelf straight back if the
|
same for every change before a switch, and puts the shelf straight back if the
|
||||||
switch then fails. The **Shelves (n)** list above the files shows each shelf's
|
switch then fails. The **Shelves (n)** list above the files shows each shelf's
|
||||||
branch, note, time and files, with **Restore** (on a clean tree only; a clean
|
branch, note, time and files, with **Restore** (refused while anything is staged or one of the shelf's own files
|
||||||
|
has changed since — the files a partial Shelve (n) left behind do not block
|
||||||
|
it; a clean
|
||||||
restore deletes the shelf, a conflicted one keeps it and lists the files under
|
restore deletes the shelf, a conflicted one keeps it and lists the files under
|
||||||
**Merge conflicts** — *Keep mine* is the branch, *Take theirs* the shelf — and
|
**Merge conflicts** — *Keep mine* is the branch, *Take theirs* the shelf — and
|
||||||
you delete the shelf yourself once they are settled) and **Delete** (two
|
you delete the shelf yourself once they are settled) and **Delete** (two
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ checkout unnecessary.
|
|||||||
|
|
||||||
| Component | Where it comes from |
|
| Component | Where it comes from |
|
||||||
|---|---|
|
|---|---|
|
||||||
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` |
|
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` — plus one synchronous `PostToolUse` entry (matcher `Artifact\|mcp__claude_ai_.*__batch`) running `scripts/artifact-export-hint.js`, which asks Claude for a viewable copy of a Doc / Slides / Design it just published (see docs/API.md § Artifacts hub) |
|
||||||
| The dashboard server | started detached by the bootstrap, from the plugin cache |
|
| The dashboard server | started detached by the bootstrap, from the plugin cache |
|
||||||
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
|
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
|
||||||
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
|
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
|
||||||
|
|||||||
+95
-3
@@ -188,7 +188,9 @@ Workspaces made before server mode, or by the host account, have none.
|
|||||||
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
||||||
holding the users' homes is marked), any NVIDIA GPU. Each user's live
|
holding the users' homes is marked), any NVIDIA GPU. Each user's live
|
||||||
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
|
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
|
||||||
user, their busiest processes on click; their home share stays in
|
user, their busiest processes on click; on hover, for an
|
||||||
|
admin or analyst — from `GET /api/users` — their role, $ spent today,
|
||||||
|
workspaces, sessions and last sign-in; their home share stays in
|
||||||
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
|
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
|
||||||
resident memory over a 1 s `/proc` sample, a process count, the busiest
|
resident memory over a 1 s `/proc` sample, a process count, the busiest
|
||||||
processes by their kernel name (never argv or env), and their home's size
|
processes by their kernel name (never argv or env), and their home's size
|
||||||
@@ -532,6 +534,48 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
|||||||
> launch file instead of tmux `-e`), but no other account's process or file.
|
> launch file instead of tmux `-e`), but no other account's process or file.
|
||||||
> The AI Proxy gateway does not cover pool tokens.
|
> The AI Proxy gateway does not cover pool tokens.
|
||||||
|
|
||||||
|
## Users' own machines (Company account)
|
||||||
|
|
||||||
|
A user with their own computer can run **AgentHub local** there and still use
|
||||||
|
the Claude account pool. The pool's logins never leave this server: their
|
||||||
|
sessions go through the server's gateway, which adds the account. Only those
|
||||||
|
sessions are reported back here — their conversation and token usage, once an
|
||||||
|
hour and when the session ends. A session on the user's own login sends
|
||||||
|
nothing, and the server never reads or runs anything on their machine.
|
||||||
|
Design and limits: `docs/plans/remote-clients/README.md`.
|
||||||
|
|
||||||
|
On the user's machine (company LAN only, plain HTTP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agenthub remote login http://10.1.13.7:4820 --user mia --name mia-laptop # hub password, asked hidden
|
||||||
|
agenthub remote status # hub, device, what was sent and when
|
||||||
|
agenthub remote upload # send now instead of waiting for the hour
|
||||||
|
agenthub remote logout # the token stops working on the server at once
|
||||||
|
```
|
||||||
|
|
||||||
|
or Settings → AI tools → **Company hub**. The machine then gets **Company
|
||||||
|
account** in the AI Proxy menu of a new Claude session; that choice is kept on
|
||||||
|
a resume. The device token lasts 90 days; after it expires or is revoked, log
|
||||||
|
in again and resume any session that was open (a running pane keeps the token
|
||||||
|
it started with).
|
||||||
|
|
||||||
|
On the server: Settings → AI tools → Company hub lists the user's own
|
||||||
|
machines (Revoke, their password again) and who opened their sessions; an
|
||||||
|
admin sees and revokes any user's machines on the **Users** page. Their
|
||||||
|
sessions are listed device › folder, with what the gateway counted; one used
|
||||||
|
with the device token outside AgentHub is flagged **unmonitored**, and an
|
||||||
|
alert rule of type **Company account outside AgentHub** (Settings → Alerts)
|
||||||
|
fires for it once it has stayed unregistered for a minute.
|
||||||
|
|
||||||
|
| Variable (server) | Default | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `AGENTHUB_REMOTE_GATE` | `warn` | `block` refuses a session AgentHub did not register (403) |
|
||||||
|
| `AGENTHUB_REMOTE_RETAIN_DAYS` | `30` | conversations older than this are deleted; token rows stay |
|
||||||
|
| `AGENTHUB_DEVICE_TOKEN_DAYS` | `90` | device token lifetime |
|
||||||
|
| `AGENTHUB_REMOTE_UPSTREAM` | `https://api.anthropic.com` | where the gateway relays |
|
||||||
|
| `AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` | `60000` | how long a session may stay unregistered before the alert fires |
|
||||||
|
| `AGENTHUB_REMOTE_FLUSH_MS` | `120000` | an upload round that stopped half-way is imported this long after its last chunk |
|
||||||
|
|
||||||
## Codex account pool
|
## Codex account pool
|
||||||
|
|
||||||
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
|
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
|
||||||
@@ -886,7 +930,7 @@ What runs as the member, and how:
|
|||||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making (or importing) a key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. **Commit signing** sits under the author: one picker signs every commit (terminal, Changes panel, agents) with one of their keys — `gpg.format ssh`, `user.signingkey` = that key's private file, `commit.gpgsign true` in their `~/.gitconfig`; a signing key they set up themselves is replaced only after a confirm — and **Import a key you have** takes a key file or paste (OpenSSH or PEM, ed25519 or RSA ≥ 2048, no passphrase: a dashboard commit has nowhere to type one) into the same `~/.config/agenthub/ssh/` as a named key (`signing`, `signing-2`…), written as them, and by default signs with it at once; only its public half is shown again. The SSH keys are shown one TAB per kind of host, each with only its own steps: **Git (GitHub, GitLab, Gitea, Backlog…)** (ED25519, for Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||||
- the workspace prompt and its context files;
|
- the workspace prompt and its context files;
|
||||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||||
@@ -1052,7 +1096,9 @@ a pane really is. Each case below is refused or bounded, with a test in
|
|||||||
running host server uses that server's own binary (`/proc/<pid>/exe`, found
|
running host server uses that server's own binary (`/proc/<pid>/exe`, found
|
||||||
from its socket in `/proc`), so installing a newer tmux under a running
|
from its socket in `/proc`), so installing a newer tmux under a running
|
||||||
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
|
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
|
||||||
100% CPU.
|
100% CPU. That binary is cached for 10 s; once its server has exited (the
|
||||||
|
last pane stopped) the call falls back to PATH's `tmux` instead of failing
|
||||||
|
with `ENOENT`.
|
||||||
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
|
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
|
||||||
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
|
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
|
||||||
the call with the binary of the member's running server (`/proc/<pid>/exe`,
|
the call with the binary of the member's running server (`/proc/<pid>/exe`,
|
||||||
@@ -1154,6 +1200,52 @@ The same re-run installs `agenthub-provision delete`, behind the Users page's
|
|||||||
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
|
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
|
||||||
action: delete") and changes nothing.
|
action: delete") and changes nothing.
|
||||||
|
|
||||||
|
The same re-run (v2.8.68) keeps members from freezing the box — on 11-02
|
||||||
|
some 36 open sessions plus test runs filled RAM and the 4 GB swap on a slow
|
||||||
|
disk, and only a hard reset brought it back. The box is for writing code;
|
||||||
|
tests run on each member's own machine:
|
||||||
|
|
||||||
|
- **Memory.** `agenthub.service` gets `MemoryMax=85%`, `MemorySwapMax=1G`,
|
||||||
|
`OOMPolicy=continue` and `OOMScoreAdjust=-100`. The cgroup holds the server
|
||||||
|
AND every member pane, so memory runs out inside the unit first and the
|
||||||
|
kernel kills one process there by `oom_score_adj`: every command a member's
|
||||||
|
Claude runs is 1000 (`CLAUDE_CODE_SHELL_PREFIX=/opt/agenthub/libexec/tool-run`,
|
||||||
|
which also tells Claude why its command died), a Terminal tab 800, an AI
|
||||||
|
session 300, the dashboard and tmux -100. A test run dies; the session
|
||||||
|
holding the conversation does not. Codex runs its commands without that
|
||||||
|
wrapper, so its commands sit at 300 beside it.
|
||||||
|
- **CPU and disk.** `pane-launch` starts every member pane at `nice 10` and
|
||||||
|
best-effort IO priority 7 (the nice cannot be taken back without root), and
|
||||||
|
defaults `VITEST_MAX_THREADS` / `VITEST_MAX_FORKS` to 2.
|
||||||
|
- **No tests or containers from Claude.** A synchronous `PreToolUse` hook
|
||||||
|
(`/opt/agenthub/app/scripts/member-guard.js`, Bash only, `ah-*` accounts
|
||||||
|
only) denies `vitest`, `jest`, `phpunit`, `pest`, `pytest`, `npm test`,
|
||||||
|
`npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`,
|
||||||
|
`podman`, … and tells Claude to leave testing to the member's machine.
|
||||||
|
A person typing a command in a Terminal tab, and Codex, are not covered.
|
||||||
|
An admin can allow them again with **Settings → AI tools → Tests on this
|
||||||
|
server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in
|
||||||
|
`/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect
|
||||||
|
at members' next Bash call, no restart.
|
||||||
|
- **At most 4 AI sessions per member at once** (`MAX_AI_SESSIONS=<n>` in
|
||||||
|
`/etc/agenthub/server.conf`, `0` = no limit). Counted from the account's
|
||||||
|
running processes (`server/lib/ai-procs.js`: `claude`, `codex`, `agy`,
|
||||||
|
`cursor-agent`, `omp`, a nested one counted once), so a `claude` typed into
|
||||||
|
a Terminal tab counts and the tab itself does not. A new pane past the
|
||||||
|
limit answers `429 ESESSIONLIMIT`; a session started by hand past it has
|
||||||
|
every prompt refused by the guard's `UserPromptSubmit` until another one
|
||||||
|
ends. The host account (admins) is not limited.
|
||||||
|
|
||||||
|
The memory caps apply to the running unit at once (the re-run does
|
||||||
|
`daemon-reload`); `OOMScoreAdjust`, the session limit on new panes and the
|
||||||
|
guard script in `/opt/agenthub/app` arrive with the next restart
|
||||||
|
(`sudo -n /usr/bin/systemctl restart --no-block agenthub.service` — panes
|
||||||
|
survive, `KillMode=process`). Panes started before the re-run keep their old
|
||||||
|
priority. Check with
|
||||||
|
`systemctl show agenthub.service -p MemoryMax,MemorySwapMax,OOMPolicy`
|
||||||
|
and, in a new member pane, `ps -o ni= -p $$; cat /proc/self/oom_score_adj;
|
||||||
|
echo $CLAUDE_CODE_SHELL_PREFIX`.
|
||||||
|
|
||||||
### Deleting a user
|
### Deleting a user
|
||||||
|
|
||||||
Users page → Edit → **Delete user** (the admin's own password, then a red
|
Users page → Edit → **Delete user** (the admin's own password, then a red
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user