Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3d6f4ca1 | |||
| 26aabd6660 | |||
| 9d07f64154 | |||
| 0f17efe3b4 | |||
| 4999325210 | |||
| 1c6dfea5b5 | |||
| df15789982 | |||
| 272a3e7d6e | |||
| 569b68ec98 | |||
| 631ea96f19 | |||
| dce32b96b7 | |||
| a0cd808456 | |||
| e3745d7504 | |||
| 8546748759 | |||
| cc2ece94c3 | |||
| 6b602c76b8 | |||
| f2f1cd9b6c | |||
| c59e196e41 | |||
| 72aef2426c | |||
| 244f999538 | |||
| 127da516ac | |||
| c0832182f2 | |||
| 9ea7758abb | |||
| f51a6a895e | |||
| 5ccbaf5c23 | |||
| c8bc4a95a9 | |||
| 65a001dad4 | |||
| 572d615d4e | |||
| 9dd97a369c | |||
| 62b4520507 | |||
| 241e19b713 | |||
| 685eb748e8 | |||
| 44426faf16 | |||
| 26952ae0d9 | |||
| 649f0599f8 | |||
| 8d2765313c | |||
| 57f02f0d89 | |||
| 50ac0f0daf | |||
| fa74cce3fc | |||
| 3113479cd2 |
+11
-15
File diff suppressed because one or more lines are too long
+114
-83
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
|
|||||||
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
// warning, not a failure: it costs the run console and `workspace shell`, nothing
|
||||||
// else.
|
// else.
|
||||||
try {
|
try {
|
||||||
const { available, upgrade } = await get("/api/run/tmux");
|
const { available, upgrade } = await get("/api/run/tmux");
|
||||||
@@ -1693,7 +1693,7 @@ function cmdOpen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
|
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
|
||||||
*/
|
*/
|
||||||
async function cmdLanesAdd(args) {
|
async function cmdLanesAdd(args) {
|
||||||
const flag = (name) => {
|
const flag = (name) => {
|
||||||
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
|
|||||||
if (base) body.base = base;
|
if (base) body.base = base;
|
||||||
if (slug) body.slug = slug;
|
if (slug) body.slug = slug;
|
||||||
if (tool) body.tool = tool;
|
if (tool) body.tool = tool;
|
||||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
|
||||||
const deadline = Date.now() + 30_000;
|
const deadline = Date.now() + 30_000;
|
||||||
let lane = provisioning;
|
let lane = provisioning;
|
||||||
while (lane.status === "provisioning" && Date.now() < deadline) {
|
while (lane.status === "provisioning" && Date.now() < deadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
({ lane } = await get(`/api/lanes/${provisioning.id}`));
|
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
|
||||||
}
|
}
|
||||||
if (lane.status === "provisioning") {
|
if (lane.status === "provisioning") {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
|
|||||||
const body = { cwd, title };
|
const body = { cwd, title };
|
||||||
if (pipeline) body.pipeline = pipeline;
|
if (pipeline) body.pipeline = pipeline;
|
||||||
if (tool) body.tool = tool;
|
if (tool) body.tool = tool;
|
||||||
const { lane } = await post("/api/lanes", body);
|
const { lane } = await post("/api/workspaces", body);
|
||||||
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||||
* (owning session died) and cap oversized hook logs, across every lane on
|
* (owning session died) and cap oversized hook logs, across every lane on
|
||||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||||
* action, no HTTP round-trip.
|
* action, no HTTP round-trip.
|
||||||
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
|
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
|
||||||
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||||
* talk to the dashboard server at all.
|
* talk to the dashboard server at all.
|
||||||
*/
|
*/
|
||||||
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
|
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
|
||||||
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||||
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
* resolution (unlike every other `workspace` subcommand) — this is meant to run
|
||||||
* against a bare repo right after `profile init`.
|
* against a bare repo right after `profile init`.
|
||||||
*/
|
*/
|
||||||
async function cmdLanesProfileCheck(args) {
|
async function cmdLanesProfileCheck(args) {
|
||||||
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
|
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
|
||||||
async function cmdLanesLifecycle(action, args) {
|
async function cmdLanesLifecycle(action, args) {
|
||||||
const id = args.find((arg) => !arg.startsWith("--"));
|
const id = args.find((arg) => !arg.startsWith("--"));
|
||||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||||
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
|
||||||
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
||||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||||
if (preflight.database) {
|
if (preflight.database) {
|
||||||
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
const body = { confirm: true, expect };
|
const body = { confirm: true, expect };
|
||||||
if (args.includes("--force")) body.force = true;
|
if (args.includes("--force")) body.force = true;
|
||||||
if (keepDb) body.keepDb = true;
|
if (keepDb) body.keepDb = true;
|
||||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
const error = result.data?.error || {};
|
const error = result.data?.error || {};
|
||||||
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||||
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
|
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
|
||||||
* whether the driving session is still breathing.
|
* whether the driving session is still breathing.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cmdLanes() {
|
async function cmdLanes() {
|
||||||
const { lanes, counts } = await get("/api/lanes");
|
const { lanes, counts } = await get("/api/workspaces");
|
||||||
if (!lanes.length) {
|
if (!lanes.length) {
|
||||||
console.log(
|
console.log(
|
||||||
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
||||||
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
|
|||||||
*
|
*
|
||||||
* Only the FIRST positional counts as an id, and only when it is all digits.
|
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||||
* Scanning the whole argv for a number would swallow flag values —
|
* Scanning the whole argv for a number would swallow flag values —
|
||||||
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
|
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
|
||||||
*
|
*
|
||||||
* @param {string[]} args - Arguments after the subcommand.
|
* @param {string[]} args - Arguments after the subcommand.
|
||||||
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||||
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
|
|||||||
return i > -1 ? args[i + 1] : undefined;
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const explicit = flagValue("lane") || flagValue("workspace");
|
const explicit = flagValue("workspace");
|
||||||
if (explicit) return { laneId: explicit, rest: args };
|
if (explicit) return { laneId: explicit, rest: args };
|
||||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||||
|
|
||||||
const tool = flagValue("tool") || toolFromEnv();
|
const tool = flagValue("tool") || toolFromEnv();
|
||||||
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||||
const { lanes } = await get("/api/lanes");
|
const { lanes } = await get("/api/workspaces");
|
||||||
|
|
||||||
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
||||||
// against the live lane list AND the directory: a pane outlives the lane it
|
// against the live lane list AND the directory: a pane outlives the lane it
|
||||||
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||||
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||||
* lane id.
|
* lane id.
|
||||||
*
|
*
|
||||||
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const { laneId, rest: laneArgs } = resolved;
|
const { laneId, rest: laneArgs } = resolved;
|
||||||
|
|
||||||
if (sub === "runtime") {
|
if (sub === "runtime") {
|
||||||
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const body = {};
|
const body = {};
|
||||||
if (laneArgs.includes("--no-build")) body.build = false;
|
if (laneArgs.includes("--no-build")) body.build = false;
|
||||||
if (laneArgs.includes("--qc")) body.qc = true;
|
if (laneArgs.includes("--qc")) body.qc = true;
|
||||||
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const deadline = Date.now() + 15 * 60 * 1000;
|
const deadline = Date.now() + 15 * 60 * 1000;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
await new Promise((r) => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
|
||||||
if (runtime.healthy) {
|
if (runtime.healthy) {
|
||||||
printRuntime(runtime);
|
printRuntime(runtime);
|
||||||
return;
|
return;
|
||||||
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === "down") {
|
if (sub === "down") {
|
||||||
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(
|
console.error(
|
||||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||||
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
}
|
}
|
||||||
const i = laneArgs.indexOf("--tail");
|
const i = laneArgs.indexOf("--tail");
|
||||||
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||||
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||||
if (!log.available) {
|
if (!log.available) {
|
||||||
console.log("no logs for this workspace yet.");
|
console.log("no logs for this workspace yet.");
|
||||||
return;
|
return;
|
||||||
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await post(
|
const result = await post(
|
||||||
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||||
{ args: laneArgs.slice(1) },
|
{ args: laneArgs.slice(1) },
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
: "merge";
|
: "merge";
|
||||||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||||
const result = await post(
|
const result = await post(
|
||||||
`/api/lanes/${laneId}/sync-base`,
|
`/api/workspaces/${laneId}/sync-base`,
|
||||||
{ mode, branch },
|
{ mode, branch },
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
|
|||||||
|
|
||||||
const resolved = await resolveLaneArg(argsAfterName);
|
const resolved = await resolveLaneArg(argsAfterName);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
|
|||||||
async function cmdFeatureList(args) {
|
async function cmdFeatureList(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
|
||||||
if (!features.length) {
|
if (!features.length) {
|
||||||
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
||||||
return;
|
return;
|
||||||
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
|
|||||||
};
|
};
|
||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
|
||||||
slug,
|
slug,
|
||||||
title: flag("title"),
|
title: flag("title"),
|
||||||
});
|
});
|
||||||
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||||
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||||
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||||
* by anything else in this codebase; a session or hook calls it explicitly. */
|
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||||
async function cmdLanesProofLink(args) {
|
async function cmdLanesProofLink(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
|
||||||
console.log(
|
console.log(
|
||||||
linked
|
linked
|
||||||
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||||
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||||
* profiles. Never run automatically; a session calls it explicitly. */
|
* profiles. Never run automatically; a session calls it explicitly. */
|
||||||
async function cmdLanesMcpSync(args) {
|
async function cmdLanesMcpSync(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes integration <name> [<id>]` — check whether a named
|
/** `agenthub workspace integration <name> [<id>]` — check whether a named
|
||||||
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||||
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||||
async function cmdLanesIntegration(args) {
|
async function cmdLanesIntegration(args) {
|
||||||
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
|
|||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { enabled } = await get(
|
const { enabled } = await get(
|
||||||
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||||
);
|
);
|
||||||
console.log(enabled ? "on" : "off");
|
console.log(enabled ? "on" : "off");
|
||||||
process.exitCode = enabled ? 0 : 1;
|
process.exitCode = enabled ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||||
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
||||||
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
||||||
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
||||||
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
|
|||||||
const resolved = await resolveLaneArg(args.slice(2));
|
const resolved = await resolveLaneArg(args.slice(2));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await get(
|
const result = await get(
|
||||||
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||||
undefined,
|
undefined,
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||||
* lane's pipeline instead of the whole thing.
|
* lane's pipeline instead of the whole thing.
|
||||||
*
|
*
|
||||||
* Every node already carries the one command that drives it (`command`, added
|
* Every node already carries the one command that drives it (`command`, added
|
||||||
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
|
|||||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||||
*
|
*
|
||||||
* - a slash command starts `claude` with it as the prompt;
|
* - a slash command starts `claude` with it as the prompt;
|
||||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
|
||||||
*
|
*
|
||||||
* With no node id it lists the nodes and their commands, which is also the
|
* With no node id it lists the nodes and their commands, which is also the
|
||||||
* error path's answer. `--print` shows the argv and runs nothing.
|
* error path's answer. `--print` shows the argv and runs nothing.
|
||||||
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
|
|||||||
const { flags, positional } = parseArgs(args);
|
const { flags, positional } = parseArgs(args);
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
||||||
// the template does. Merge, so the listing can still mark the current node.
|
// the template does. Merge, so the listing can still mark the current node.
|
||||||
const { pipelines } = await get("/api/lanes/pipelines");
|
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||||
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
||||||
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
||||||
...n,
|
...n,
|
||||||
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||||
* template a lane renders against. `agenthub lanes add --pipeline` could only set
|
* template a lane renders against. `agenthub workspace add --pipeline` could only set
|
||||||
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||||
* was stuck on `default` with no way back to a 16-node template.
|
* was stuck on `default` with no way back to a 16-node template.
|
||||||
*/
|
*/
|
||||||
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
|
|||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
const { pipelines } = await get("/api/lanes/pipelines");
|
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
|
||||||
console.log(
|
console.log(
|
||||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||||
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
|
|||||||
async function cmdLanesShell(args) {
|
async function cmdLanesShell(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
const sessionName = `agenthub-lane-${lane.id}`;
|
const sessionName = `agenthub-lane-${lane.id}`;
|
||||||
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
|
|||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await get(
|
const result = await get(
|
||||||
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||||
undefined,
|
undefined,
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
|
|||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const laneId = resolved.laneId;
|
const laneId = resolved.laneId;
|
||||||
|
|
||||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
|
||||||
stage,
|
stage,
|
||||||
status: flag("status"),
|
status: flag("status"),
|
||||||
evidence: flag("evidence"),
|
evidence: flag("evidence"),
|
||||||
@@ -2845,18 +2845,18 @@ const COMMAND_GROUPS = [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"server",
|
"server",
|
||||||
"setup|off|status [--hostname name] [--admin name]",
|
"setup|install-system|tools|off|status [--hostname name] [--admin name]",
|
||||||
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"users",
|
"users",
|
||||||
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
"list|add|passwd|role|disable|enable|provision|assign|audit [name]",
|
||||||
"Server mode's accounts (add: --role admin|analyst|developer)",
|
"Server mode's accounts (add: --role admin|analyst|developer)",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"accounts",
|
"accounts",
|
||||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex|cursor]",
|
||||||
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
"Claude/Codex/Cursor account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||||
],
|
],
|
||||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||||
],
|
],
|
||||||
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
|
|||||||
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
||||||
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
||||||
|
|
||||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
|
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
|
||||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||||
|
|
||||||
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
||||||
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
||||||
@@ -3512,8 +3512,8 @@ function replHelp() {
|
|||||||
/** Detailed help for one command (or command prefix): every catalog entry
|
/** Detailed help for one command (or command prefix): every catalog entry
|
||||||
* whose invocation starts with the token. Returns false if none matched. */
|
* whose invocation starts with the token. Returns false if none matched. */
|
||||||
function commandHelp(token) {
|
function commandHelp(token) {
|
||||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
|
||||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
// `help workspaces` finds the `workspace …` rows and vice versa.
|
||||||
const canon = (words) => {
|
const canon = (words) => {
|
||||||
const [head, ...tail] = words.split(" ");
|
const [head, ...tail] = words.split(" ");
|
||||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||||
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
|
|||||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||||
// decides whether to fall back to an offline reader or report the refusal.
|
// decides whether to fall back to an offline reader or report the refusal.
|
||||||
|
|
||||||
/** Command names that are another command under the user-facing word.
|
/** Command names that are another command under the user-facing word. */
|
||||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
const COMMAND_ALIASES = { workspaces: "workspace" };
|
||||||
* `--lane` are the OLD names, still working but deprecated. Removing them is
|
|
||||||
* step 4 of docs/plans/workspace-rename/README.md — not before the third
|
|
||||||
* release after the first one that shipped these aliases. */
|
|
||||||
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
|
|
||||||
const canonicalArgv = (argv) =>
|
const canonicalArgv = (argv) =>
|
||||||
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
||||||
|
|
||||||
/** One stderr line when an old name is typed, so people and agents move over
|
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
|
||||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
|
||||||
function warnOldNames(argv) {
|
* — never "Unknown command" or help, so a script written before says why. */
|
||||||
const old = [];
|
function refuseOldNames(argv) {
|
||||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
if (argv[0] === "lanes") {
|
||||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
console.error(c.red("✖ renamed: use agenthub workspace …"));
|
||||||
if (old.length) {
|
process.exit(1);
|
||||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
}
|
||||||
|
if (argv.includes("--lane")) {
|
||||||
|
console.error(c.red("✖ renamed: use --workspace <id>"));
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
|
|||||||
for (const [name] of items) set.add(name.split(" ")[0]);
|
for (const [name] of items) set.add(name.split(" ")[0]);
|
||||||
}
|
}
|
||||||
set.add("help");
|
set.add("help");
|
||||||
// The old names stay typeable (and completable) for scripts written before.
|
|
||||||
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
||||||
return [...set];
|
return [...set];
|
||||||
})();
|
})();
|
||||||
@@ -3812,8 +3810,8 @@ const COMMANDS = (() => {
|
|||||||
const SUBCOMMANDS = {
|
const SUBCOMMANDS = {
|
||||||
alerts: ["ack", "ack-all"],
|
alerts: ["ack", "ack-all"],
|
||||||
service: ["install", "uninstall", "status"],
|
service: ["install", "uninstall", "status"],
|
||||||
server: ["setup", "off", "status"],
|
server: ["setup", "install-system", "tools", "off", "status"],
|
||||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
users: ["list", "add", "passwd", "role", "disable", "enable", "provision", "assign", "audit"],
|
||||||
accounts: ["list", "add", "remove", "pin", "unpin"],
|
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||||
pricing: ["set", "delete", "reset"],
|
pricing: ["set", "delete", "reset"],
|
||||||
webhooks: ["test"],
|
webhooks: ["test"],
|
||||||
@@ -3905,7 +3903,7 @@ async function newPassword(label) {
|
|||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub server setup|off|status` — turn server mode on or off. */
|
/** `agenthub server setup|install-system|tools|off|status` — turn server mode on or off. */
|
||||||
async function cmdServer(sub, flags) {
|
async function cmdServer(sub, flags) {
|
||||||
const { settingsEnv, auth } = serverAuth();
|
const { settingsEnv, auth } = serverAuth();
|
||||||
const file = settingsEnv.settingsFilePath();
|
const file = settingsEnv.settingsFilePath();
|
||||||
@@ -4062,7 +4060,7 @@ async function installSystem(flags, settingsEnv) {
|
|||||||
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
|
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
|
||||||
}
|
}
|
||||||
const which = (bin) =>
|
const which = (bin) =>
|
||||||
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
|
spawnSync("sh", ["-c", 'command -v "$1"', "sh", bin], { encoding: "utf8" }).stdout.trim();
|
||||||
const host = os.userInfo().username;
|
const host = os.userInfo().username;
|
||||||
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||||
const data = getDataDir();
|
const data = getDataDir();
|
||||||
@@ -4328,8 +4326,36 @@ async function cmdCodexAccounts(sub, name, flags) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `agenthub accounts … --tool cursor` — the Cursor account pool (server/lib/cursor-pool.js). */
|
||||||
|
async function cmdCursorAccounts(sub, name) {
|
||||||
|
const pool = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||||
|
if (sub === "add") {
|
||||||
|
console.log(
|
||||||
|
c.dim(" Create an API key in the Cursor dashboard (Integrations), then paste it.")
|
||||||
|
);
|
||||||
|
const added = pool.addAccount(name, {
|
||||||
|
apiKey: await askPassword(`Cursor API key for ${name}: `),
|
||||||
|
});
|
||||||
|
console.log(`${c.green("✔")} ${added.name} added`);
|
||||||
|
console.log(c.dim(" New Cursor sessions now spread over the pool's accounts."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "remove") {
|
||||||
|
pool.removeAccount(name);
|
||||||
|
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub !== undefined && sub !== "list") {
|
||||||
|
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool cursor");
|
||||||
|
}
|
||||||
|
table(
|
||||||
|
["cursor account", "live", "pinned"],
|
||||||
|
pool.poolStatus().map((a) => [a.name, a.livePanes, pinnedUsers("cursor", a.name)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
|
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex|cursor] —
|
||||||
* that user's new sessions run on that account (server/lib/account-pins.js).
|
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||||
*/
|
*/
|
||||||
async function cmdAccountPin(sub, positional, tool) {
|
async function cmdAccountPin(sub, positional, tool) {
|
||||||
@@ -4337,7 +4363,9 @@ async function cmdAccountPin(sub, positional, tool) {
|
|||||||
const [first, second] = positional;
|
const [first, second] = positional;
|
||||||
const username = sub === "pin" ? second : first;
|
const username = sub === "pin" ? second : first;
|
||||||
if (!username || (sub === "pin" && !first)) {
|
if (!username || (sub === "pin" && !first)) {
|
||||||
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
|
throw new Error(
|
||||||
|
"usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||||
.db.prepare("SELECT id FROM users WHERE username = ?")
|
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||||
@@ -4387,14 +4415,15 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||||
const name = positional[0];
|
const name = positional[0];
|
||||||
if (sub === "add" || sub === "remove") {
|
if (sub === "add" || sub === "remove") {
|
||||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
|
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex|cursor]`);
|
||||||
}
|
}
|
||||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
if (flags.tool != null && !["claude", "codex", "cursor"].includes(flags.tool)) {
|
||||||
throw new Error("--tool is claude (the default) or codex");
|
throw new Error("--tool is claude (the default), codex or cursor");
|
||||||
}
|
}
|
||||||
if (sub === "pin" || sub === "unpin")
|
if (sub === "pin" || sub === "unpin")
|
||||||
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||||
|
if (flags.tool === "cursor") return cmdCursorAccounts(sub, name);
|
||||||
if (sub === "add") {
|
if (sub === "add") {
|
||||||
console.log(
|
console.log(
|
||||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||||
@@ -4450,11 +4479,13 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
);
|
);
|
||||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||||
|
const cursor = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||||
|
if (cursor.poolStatus().length) await cmdCursorAccounts("list", null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
c.red(
|
c.red(
|
||||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
|
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -4465,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||||
* points, not here, to avoid recursive REPLs. */
|
* points, not here, to avoid recursive REPLs. */
|
||||||
async function runCommand(argv) {
|
async function runCommand(argv) {
|
||||||
warnOldNames(argv);
|
refuseOldNames(argv);
|
||||||
const [cmd, ...rest] = canonicalArgv(argv);
|
const [cmd, ...rest] = canonicalArgv(argv);
|
||||||
const { flags, positional } = parseArgs(rest);
|
const { flags, positional } = parseArgs(rest);
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
@@ -4538,7 +4569,7 @@ async function runCommand(argv) {
|
|||||||
return cmdReinstallHooks();
|
return cmdReinstallHooks();
|
||||||
case "update-check":
|
case "update-check":
|
||||||
return cmdUpdateCheck();
|
return cmdUpdateCheck();
|
||||||
case "lanes":
|
case "workspace":
|
||||||
if (rest[0] === "add") {
|
if (rest[0] === "add") {
|
||||||
return cmdLanesAdd(rest.slice(1));
|
return cmdLanesAdd(rest.slice(1));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
|||||||
|
|
||||||
The client is a single-page application (SPA) built with modern web technologies:
|
The client is a single-page application (SPA) built with modern web technologies:
|
||||||
|
|
||||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
- **React 19** - Component-based UI with hooks and concurrent features
|
||||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
|
||||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||||
|
|
||||||
@@ -1215,7 +1215,7 @@ graph TB
|
|||||||
|
|
||||||
The client is a production-ready React application with:
|
The client is a production-ready React application with:
|
||||||
|
|
||||||
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
|
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
|
||||||
- ⚡ **Real-time** - WebSocket integration for live updates
|
- ⚡ **Real-time** - WebSocket integration for live updates
|
||||||
- 🧪 **Tested** - Vitest + React Testing Library
|
- 🧪 **Tested** - Vitest + React Testing Library
|
||||||
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
||||||
|
|||||||
Generated
+462
-162
@@ -15,36 +15,36 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/maple-mono": "^5.3.0",
|
"@fontsource/maple-mono": "^5.3.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^1.61.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^7.18.4",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
@@ -450,10 +450,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/merge": {
|
"node_modules/@codemirror/merge": {
|
||||||
"version": "6.12.2",
|
"version": "6.12.3",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
|
||||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.0.0",
|
"@codemirror/language": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -478,10 +477,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/view": {
|
"node_modules/@codemirror/view": {
|
||||||
"version": "6.43.14",
|
"version": "6.43.15",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.7.0",
|
"@codemirror/state": "^6.7.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1251,15 +1249,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
|
||||||
"version": "1.23.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
|
||||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1670,7 +1659,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
||||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5"
|
"@babel/runtime": "^7.12.5"
|
||||||
},
|
},
|
||||||
@@ -2117,30 +2105,21 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
|
||||||
"version": "15.7.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.3.7",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
@@ -2149,6 +2128,326 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||||
@@ -2291,59 +2590,38 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-canvas": {
|
|
||||||
"version": "0.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
|
||||||
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/addon-clipboard": {
|
"node_modules/@xterm/addon-clipboard": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
|
||||||
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
|
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-base64": "^3.7.5"
|
"js-base64": "^3.7.5"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.4.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-fit": {
|
"node_modules/@xterm/addon-fit": {
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-search": {
|
"node_modules/@xterm/addon-search": {
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-web-links": {
|
"node_modules/@xterm/addon-web-links": {
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
|
||||||
"license": "MIT",
|
},
|
||||||
"peerDependencies": {
|
"node_modules/@xterm/addon-webgl": {
|
||||||
"@xterm/xterm": "^5.0.0"
|
"version": "0.19.0",
|
||||||
}
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||||
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "5.5.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
@@ -2740,6 +3018,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crelt": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||||
@@ -3703,9 +3993,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.4.2",
|
"version": "26.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -3720,7 +4010,6 @@
|
|||||||
"url": "https://www.locize.com"
|
"url": "https://www.locize.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6 || ^7"
|
||||||
},
|
},
|
||||||
@@ -3731,12 +4020,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-browser-languagedetector": {
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
"version": "8.2.1",
|
"version": "8.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
|
||||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.23.2"
|
"@babel/runtime": "^7.29.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
@@ -3921,6 +4209,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
@@ -4019,18 +4308,6 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"loose-envify": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
@@ -4049,12 +4326,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.474.0",
|
"version": "1.61.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||||
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
|
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
@@ -5375,35 +5657,28 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "19.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "19.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"scheduler": "^0.28.0"
|
||||||
"scheduler": "^0.23.2"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.1"
|
"react": "^19.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.16",
|
"version": "17.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.7",
|
"@babel/runtime": "^7.29.7",
|
||||||
"html-parse-stringify": "^4.0.1",
|
"html-parse-stringify": "^4.0.1",
|
||||||
@@ -5472,35 +5747,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.6",
|
"version": "7.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.4"
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8"
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "6.30.6",
|
"version": "7.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
||||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.4",
|
"react-router": "7.18.4"
|
||||||
"react-router": "6.30.6"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8",
|
"react": ">=18",
|
||||||
"react-dom": ">=16.8"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
@@ -5748,13 +6027,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
@@ -5766,6 +6041,11 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
|
||||||
|
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -6171,17 +6451,37 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc"
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
|
|||||||
+14
-14
@@ -18,36 +18,36 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/maple-mono": "^5.3.0",
|
"@fontsource/maple-mono": "^5.3.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^1.61.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^7.18.4",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 465 KiB |
@@ -49,7 +49,7 @@
|
|||||||
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
|
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
|
||||||
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
|
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
|
||||||
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
|
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
|
||||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
|
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(792,300)">
|
<g transform="translate(792,300)">
|
||||||
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>
|
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@@ -24,10 +24,18 @@ export function DocumentTitle() {
|
|||||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||||
} else if (path.startsWith("/sessions")) {
|
} else if (path.startsWith("/sessions")) {
|
||||||
title = t("sessions");
|
title = t("sessions");
|
||||||
|
} else if (path.startsWith("/snapshot/")) {
|
||||||
|
title = t("snapshot");
|
||||||
} else if (path.startsWith("/workspace")) {
|
} else if (path.startsWith("/workspace")) {
|
||||||
title = t("workspace");
|
title = t("workspace");
|
||||||
} else if (path.startsWith("/settings")) {
|
} else if (path.startsWith("/settings")) {
|
||||||
title = t("settings");
|
title = t("settings");
|
||||||
|
} else if (path.startsWith("/artifacts")) {
|
||||||
|
title = t("artifacts");
|
||||||
|
} else if (path.startsWith("/files")) {
|
||||||
|
title = t("files");
|
||||||
|
} else if (path.startsWith("/admin")) {
|
||||||
|
title = t("admin");
|
||||||
} else if (path !== "/") {
|
} else if (path !== "/") {
|
||||||
title = t("notFound");
|
title = t("notFound");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
||||||
* layer or a header drawn over a pane can cover it — opened from inside a
|
* layer or a header drawn over a pane can cover it — opened from inside a
|
||||||
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
||||||
* layer, under anything stacked above it.
|
* layer, under anything stacked above it. `useDialog` takes the focus while it
|
||||||
|
* is open and gives it back, and its Escape closes only this, the top dialog.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useDialog } from "../hooks/useDialog";
|
||||||
|
|
||||||
export function ImageLightbox({
|
export function ImageLightbox({
|
||||||
src,
|
src,
|
||||||
@@ -20,18 +22,18 @@ export function ImageLightbox({
|
|||||||
alt: string;
|
alt: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
const panel = useRef<HTMLDivElement>(null);
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
useDialog(true, panel, onClose);
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
|
||||||
}, [onClose]);
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={panel}
|
||||||
|
tabIndex={-1}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
data-testid="image-lightbox"
|
data-testid="image-lightbox"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8 outline-none"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
|
|||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
||||||
import { PathPickButton } from "./PathPickButton";
|
import { PathPickButton } from "./PathPickButton";
|
||||||
|
import { copyText } from "../lib/clipboard";
|
||||||
|
|
||||||
type Mode = "rescan" | "path" | "upload" | "backup";
|
type Mode = "rescan" | "path" | "upload" | "backup";
|
||||||
|
|
||||||
@@ -247,13 +248,9 @@ export function ImportHistory() {
|
|||||||
|
|
||||||
const copyArchiveCmd = async () => {
|
const copyArchiveCmd = async () => {
|
||||||
if (!guide) return;
|
if (!guide) return;
|
||||||
try {
|
if (!(await copyText(guide.archive_command, true))) return;
|
||||||
await navigator.clipboard.writeText(guide.archive_command);
|
setCopied(true);
|
||||||
setCopied(true);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
} catch {
|
|
||||||
/* clipboard unavailable */
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressText = (() => {
|
const progressText = (() => {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
|||||||
import { useDataScope } from "../lib/dataScope";
|
import { useDataScope } from "../lib/dataScope";
|
||||||
import type { ScopeMode } from "../lib/dataScope";
|
import type { ScopeMode } from "../lib/dataScope";
|
||||||
import { PathPickButton } from "./PathPickButton";
|
import { PathPickButton } from "./PathPickButton";
|
||||||
|
import { copyText } from "../lib/clipboard";
|
||||||
|
|
||||||
/** Data-scope choices in display order (the radio group's arrow-key order). */
|
/** Data-scope choices in display order (the radio group's arrow-key order). */
|
||||||
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
|
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
|
||||||
@@ -863,7 +864,7 @@ export function RemoteSources() {
|
|||||||
: t("remoteSources.enable", "Enable")}
|
: t("remoteSources.enable", "Enable")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
||||||
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
|
<MenuItem onSelect={pick((s) => void copyText(s.host))}>
|
||||||
{t("rowMenu.copyHost")}
|
{t("rowMenu.copyHost")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
|||||||
moment = null,
|
moment = null,
|
||||||
}: SagiAvatarProps) {
|
}: SagiAvatarProps) {
|
||||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||||
const rafRef = useRef<number>();
|
const rafRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||||
import { PET_NAME_MAX } from "./prefs";
|
import { PET_NAME_MAX } from "./prefs";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
|
||||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||||
const ATTENTION_SHOWN = 4;
|
const ATTENTION_SHOWN = 4;
|
||||||
@@ -70,7 +71,7 @@ export function PetNameButton({
|
|||||||
onChange={(e) => setRenaming(e.target.value)}
|
onChange={(e) => setRenaming(e.target.value)}
|
||||||
onBlur={save}
|
onBlur={save}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (isSubmitEnter(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
save();
|
save();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
|
|||||||
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||||
|
|
||||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const lastBubbleAt = useRef(0);
|
const lastBubbleAt = useRef(0);
|
||||||
const burst = useRef<Burst | null>(null);
|
const burst = useRef<Burst | null>(null);
|
||||||
const momentKey = useRef(0);
|
const momentKey = useRef(0);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
* its AI Tools label). Renders nothing for a Claude session, so the session
|
||||||
* list and session header look as before for every Claude row.
|
* header looks as before for every Claude row.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { api } from "../lib/api";
|
|||||||
import { ConfirmModal } from "./ConfirmModal";
|
import { ConfirmModal } from "./ConfirmModal";
|
||||||
import { Checkbox } from "./Checkbox";
|
import { Checkbox } from "./Checkbox";
|
||||||
import { CopyButton } from "./event-views/primitives";
|
import { CopyButton } from "./event-views/primitives";
|
||||||
|
import { copyText } from "../lib/clipboard";
|
||||||
|
|
||||||
export function snapshotUrl(token: string): string {
|
export function snapshotUrl(token: string): string {
|
||||||
return `${window.location.origin}/snapshot/${token}`;
|
return `${window.location.origin}/snapshot/${token}`;
|
||||||
@@ -33,7 +34,7 @@ export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
|||||||
try {
|
try {
|
||||||
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||||
setToken(snapshot.token);
|
setToken(snapshot.token);
|
||||||
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
void copyText(snapshotUrl(snapshot.token));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* — 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 —, re-read on session
|
||||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on 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
|
||||||
* 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
|
||||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||||
@@ -50,9 +51,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
|||||||
codex: "Codex",
|
codex: "Codex",
|
||||||
antigravity: "Antigravity",
|
antigravity: "Antigravity",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
grok: "Grok",
|
|
||||||
kimi: "Kimi",
|
|
||||||
opencode: "OpenCode Go",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function localDate(d = new Date()): string {
|
function localDate(d = new Date()): string {
|
||||||
@@ -281,7 +279,7 @@ function QuotaChips() {
|
|||||||
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.
|
||||||
const closeTimer = useRef<number>();
|
const closeTimer = useRef<number | undefined>(undefined);
|
||||||
const show = () => {
|
const show = () => {
|
||||||
window.clearTimeout(closeTimer.current);
|
window.clearTimeout(closeTimer.current);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@@ -406,7 +404,7 @@ export function StatusBar({
|
|||||||
const gen = costGen.current;
|
const gen = costGen.current;
|
||||||
const live = () => gen === costGen.current;
|
const live = () => gen === costGen.current;
|
||||||
api.pricing
|
api.pricing
|
||||||
.totalCost({ date: localDate() })
|
.totalCost({ date: localDate(), mine: true })
|
||||||
.then((r) => live() && setToday(r))
|
.then((r) => live() && setToday(r))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.budgets
|
api.budgets
|
||||||
|
|||||||
@@ -17,8 +17,15 @@
|
|||||||
* keeps answering while the chain installs. New skills/slash commands still
|
* keeps answering while the chain installs. New skills/slash commands still
|
||||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||||
* background process can do for it. Escape, the backdrop and × close it for
|
* background process can do for it. Escape, the backdrop and × close it for
|
||||||
* this page load only (never while applying); "Skip this version" is the one
|
* this page load only — also while an update applies: it keeps running in the
|
||||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
* background (the page still reloads itself once the new server is up), and a
|
||||||
|
* failure re-opens the modal with its error. While hidden — or once the status
|
||||||
|
* stops offering the update mid-run — a small chip in the corner keeps showing
|
||||||
|
* it, with the plugin chain's current step from `plugin_update_progress`, and a
|
||||||
|
* click on it shows the modal again. A chain the server reports as running
|
||||||
|
* (started before a reload or from another tab) is adopted the same way;
|
||||||
|
* "Skip this version" is the one
|
||||||
|
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
||||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||||
* and starts a Claude Code session there with the error as its prompt — the
|
* and starts a Claude Code session there with the error as its prompt — the
|
||||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||||
@@ -182,6 +189,8 @@ export function UpdateNotifier() {
|
|||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
const [fixing, setFixing] = useState(false);
|
const [fixing, setFixing] = useState(false);
|
||||||
|
/** The running plugin chain's step, from the polls while it runs. */
|
||||||
|
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||||
@@ -236,20 +245,23 @@ export function UpdateNotifier() {
|
|||||||
status?.latest_release ||
|
status?.latest_release ||
|
||||||
status?.remote_sha ||
|
status?.remote_sha ||
|
||||||
(installed ? `installed:${installed.root}` : null);
|
(installed ? `installed:${installed.root}` : null);
|
||||||
// Updating replaces the host's install: only an admin is offered it.
|
// Updating replaces the host's install: only an admin is offered it. A
|
||||||
|
// running update stays shown (unless closed) even once the status stops
|
||||||
|
// offering it — the new version may already be on disk before it restarts.
|
||||||
const show = Boolean(
|
const show = Boolean(
|
||||||
isAdmin &&
|
isAdmin &&
|
||||||
status?.update_available &&
|
|
||||||
dismissKey &&
|
dismissKey &&
|
||||||
dismissedSha !== dismissKey &&
|
closedKey !== dismissKey &&
|
||||||
closedKey !== dismissKey
|
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
|
||||||
);
|
);
|
||||||
const busy = applying || restarting || fixing;
|
const busy = applying || restarting || fixing;
|
||||||
|
|
||||||
|
// Closing while an update applies only hides it: the apply request and the
|
||||||
|
// poll that reloads the page keep running, and a failure re-opens it.
|
||||||
const closeForNow = useCallback(() => {
|
const closeForNow = useCallback(() => {
|
||||||
if (busy || !dismissKey) return;
|
if (!dismissKey) return;
|
||||||
setClosedKey(dismissKey);
|
setClosedKey(dismissKey);
|
||||||
}, [busy, dismissKey]);
|
}, [dismissKey]);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
if (busy || !dismissKey) return;
|
if (busy || !dismissKey) return;
|
||||||
@@ -261,8 +273,8 @@ export function UpdateNotifier() {
|
|||||||
setDismissedSha(dismissKey);
|
setDismissedSha(dismissKey);
|
||||||
}, [busy, dismissKey]);
|
}, [busy, dismissKey]);
|
||||||
|
|
||||||
// Escape closes for now — never while an update is applying.
|
// Escape closes for now — an applying update carries on in the background.
|
||||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
useDialog(show, panelRef, closeForNow);
|
||||||
|
|
||||||
const copyCmd = async () => {
|
const copyCmd = async () => {
|
||||||
if (!status?.manual_command) return;
|
if (!status?.manual_command) return;
|
||||||
@@ -307,6 +319,7 @@ export function UpdateNotifier() {
|
|||||||
api.updates
|
api.updates
|
||||||
.status()
|
.status()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
|
setProgress(s.plugin_update_progress ?? null);
|
||||||
const v = s.last_plugin_update;
|
const v = s.last_plugin_update;
|
||||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||||
@@ -315,6 +328,7 @@ export function UpdateNotifier() {
|
|||||||
syncFromPayload(s);
|
syncFromPayload(s);
|
||||||
setRestarting(false);
|
setRestarting(false);
|
||||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||||
|
else setClosedKey(null); // closed while it ran: show why it failed
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A new version answering is back too, even if a poll never
|
// A new version answering is back too, even if a poll never
|
||||||
@@ -333,9 +347,23 @@ export function UpdateNotifier() {
|
|||||||
[syncFromPayload, t]
|
[syncFromPayload, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A chain already running on the server (started before a reload, in another
|
||||||
|
// tab, or from the old bundle) is adopted: this tab shows its steps, then
|
||||||
|
// reloads onto the new server like the tab that started it.
|
||||||
|
const running = status?.plugin_update_progress ?? null;
|
||||||
|
const adoptedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
|
||||||
|
adoptedRef.current = true;
|
||||||
|
setProgress(running);
|
||||||
|
setRestarting(true);
|
||||||
|
pollUntilBack(true, status?.local_version ?? null);
|
||||||
|
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
|
||||||
|
|
||||||
const applyNow = async () => {
|
const applyNow = async () => {
|
||||||
if (applying || restarting) return;
|
if (applying || restarting) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgress(null);
|
||||||
setApplying(true);
|
setApplying(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.updates.apply();
|
const result = await api.updates.apply();
|
||||||
@@ -359,6 +387,8 @@ export function UpdateNotifier() {
|
|||||||
} finally {
|
} finally {
|
||||||
setApplying(false);
|
setApplying(false);
|
||||||
}
|
}
|
||||||
|
// Not applied: re-open a modal closed while it ran, so the error is seen.
|
||||||
|
setClosedKey(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||||
@@ -384,6 +414,54 @@ export function UpdateNotifier() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stepText = progress?.step
|
||||||
|
? t(`pluginStep.${progress.step}`, {
|
||||||
|
index: progress.index,
|
||||||
|
total: progress.total,
|
||||||
|
defaultValue: progress.step,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Hidden while it runs — closed, or the status no longer offers the update
|
||||||
|
// (the new version is already on disk): a chip keeps it in sight.
|
||||||
|
if (!show && isAdmin && (applying || restarting)) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="update-chip"
|
||||||
|
onClick={() => setClosedKey(null)}
|
||||||
|
title={t("chipHint")}
|
||||||
|
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||||||
|
<span className="truncate">
|
||||||
|
{status?.latest_release
|
||||||
|
? t("chipTitle", { version: status.latest_release })
|
||||||
|
: t("updating")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||||||
|
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||||||
|
</span>
|
||||||
|
{progress && progress.total > 0 ? (
|
||||||
|
<span
|
||||||
|
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={progress.index}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={progress.total}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-full bg-accent-solid transition-all"
|
||||||
|
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!show || !status) return null;
|
if (!show || !status) return null;
|
||||||
|
|
||||||
const refLabel = status.remote_ref || "origin";
|
const refLabel = status.remote_ref || "origin";
|
||||||
@@ -436,7 +514,6 @@ export function UpdateNotifier() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeForNow}
|
onClick={closeForNow}
|
||||||
disabled={busy}
|
|
||||||
aria-label={t("later")}
|
aria-label={t("later")}
|
||||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
@@ -513,7 +590,14 @@ export function UpdateNotifier() {
|
|||||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||||
dependencies before it restarts, so it takes minutes, not
|
dependencies before it restarts, so it takes minutes, not
|
||||||
seconds — saying "restarting" there would look hung. */}
|
seconds — saying "restarting" there would look hung. */}
|
||||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
<span>
|
||||||
|
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||||
|
{stepText ? (
|
||||||
|
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||||||
|
{stepText}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||||
|
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
|
||||||
|
* server like the other preferences) arrange the list under foldable headers —
|
||||||
|
* each group in its order, then "Ungrouped" — inside each owner group when
|
||||||
|
* there are owner groups. A workspace joins a group from its menu, by a drop
|
||||||
|
* on the group's header, by a drag that lands it below a row of that
|
||||||
|
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
|
||||||
|
* another to reorder the groups, and its menu renames, moves, folds every
|
||||||
|
* group, adds a new workspace straight into it or deletes it (its
|
||||||
|
* workspaces stay, ungrouped). A folded header still shows the worst state
|
||||||
|
* of its workspaces (needs you, error, running) and marks the selected one.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -30,7 +40,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
|
||||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
@@ -46,7 +56,31 @@ import {
|
|||||||
writeLaneOrder,
|
writeLaneOrder,
|
||||||
} from "../lib/laneOrderStorage";
|
} from "../lib/laneOrderStorage";
|
||||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
import {
|
||||||
|
arrangeByGroup,
|
||||||
|
assignGroup,
|
||||||
|
createGroup,
|
||||||
|
deleteGroup,
|
||||||
|
groupOf,
|
||||||
|
moveGroup,
|
||||||
|
moveGroupBefore,
|
||||||
|
pruneWorkspaceGroups,
|
||||||
|
renameGroup,
|
||||||
|
setAllFolded,
|
||||||
|
setGroupFolded,
|
||||||
|
useWorkspaceGroups,
|
||||||
|
type WorkspaceGroup,
|
||||||
|
} from "../lib/workspaceGroups";
|
||||||
|
import { confirmDialog } from "../lib/confirm";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
|
||||||
|
import {
|
||||||
|
LANE_DRAG_TYPE,
|
||||||
|
WorkspaceRow,
|
||||||
|
isLaneDrag,
|
||||||
|
workspaceDot,
|
||||||
|
workspaceDotLabel,
|
||||||
|
workspaceName,
|
||||||
|
} from "./lanes/WorkspaceRow";
|
||||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||||
import { UserAvatar } from "./users/UserAvatar";
|
import { UserAvatar } from "./users/UserAvatar";
|
||||||
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
|
|||||||
);
|
);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||||
|
// "New workspace in this group": the group the added workspace joins.
|
||||||
|
const [addToGroup, setAddToGroup] = useState<string | null>(null);
|
||||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||||
const [orderTick, setOrderTick] = useState(0);
|
const [orderTick, setOrderTick] = useState(0);
|
||||||
|
const groups = useWorkspaceGroups();
|
||||||
|
const hasGroups = groups.groups.length > 0;
|
||||||
|
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
|
||||||
|
const [groupMenu, setGroupMenu] = useState<{
|
||||||
|
group: WorkspaceGroup;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
// A removed workspace leaves the stored groups (they hold ids only).
|
||||||
|
const laneIdsKey = lanes.map((l) => l.id).join(",");
|
||||||
|
useEffect(() => {
|
||||||
|
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
|
||||||
|
}, [laneIdsKey]);
|
||||||
|
|
||||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||||
// status or session moves, so that is when the live-pane list is re-read;
|
// status or session moves, so that is when the live-pane list is re-read;
|
||||||
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
|
|||||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAdd = (sourceRepo?: string) => {
|
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
|
||||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||||
|
setAddToGroup(groupId);
|
||||||
setAddOpen(true);
|
setAddOpen(true);
|
||||||
api.run
|
api.run
|
||||||
.cwds()
|
.cwds()
|
||||||
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
|
|||||||
const toolFilter =
|
const toolFilter =
|
||||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const list = sortByStoredOrder(lanes, order).filter(
|
const list = arrangeByGroup(
|
||||||
(l) => !toolFilter || l.tool === toolFilter
|
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||||
|
groups
|
||||||
);
|
);
|
||||||
if (!grouped) return list;
|
if (!grouped) return list;
|
||||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||||
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
|
|||||||
return [...list].sort(
|
return [...list].sort(
|
||||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||||
);
|
);
|
||||||
}, [lanes, order, toolFilter, grouped, meId]);
|
}, [lanes, order, toolFilter, grouped, meId, groups]);
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const nameHit = (l: Lane) =>
|
const nameHit = (l: Lane) =>
|
||||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||||
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
|
|||||||
setOrderTick((n) => n + 1);
|
setOrderTick((n) => n + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
|
||||||
|
// screen and joins the next group instead (the arrangement would otherwise
|
||||||
|
// put it straight back): last of the group above, first of the one below —
|
||||||
|
// opened if folded, so the focused row stays shown. The shown order is
|
||||||
|
// written as it stands, so the store agrees. Never across an owner's section.
|
||||||
|
const nudge = (from: number, to: number) => {
|
||||||
|
const lane = sorted[from];
|
||||||
|
const next = sorted[to];
|
||||||
|
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
|
||||||
|
const target = groupOf(groups, next.id);
|
||||||
|
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
|
||||||
|
assignGroup(lane.id, target?.id ?? null);
|
||||||
|
if (target?.folded) setGroupFolded(target.id, false);
|
||||||
|
reorder((ids) => [...ids]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reorder((ids) => moveLane(ids, from, to));
|
||||||
|
};
|
||||||
|
|
||||||
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
||||||
// workspace's session list, the space above or below a row — lands too.
|
// workspace's session list, the space above or below a row — lands too.
|
||||||
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
||||||
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
|
|||||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||||
// Number("") is 0: an empty payload must not move the first row.
|
// Number("") is 0: an empty payload must not move the first row.
|
||||||
const from = raw === "" ? NaN : Number(raw);
|
const from = raw === "" ? NaN : Number(raw);
|
||||||
|
if (!Number.isInteger(from)) return;
|
||||||
|
const lane = sorted[from];
|
||||||
|
// Dropped on a group's header: into that group, at its top.
|
||||||
|
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
|
||||||
|
if (header && lane) {
|
||||||
|
const gid = header.dataset.groupDrop || null;
|
||||||
|
assignGroup(lane.id, gid);
|
||||||
|
const first = sorted.findIndex(
|
||||||
|
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
|
||||||
|
);
|
||||||
|
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const before = dropIndexAt(e.clientY);
|
const before = dropIndexAt(e.clientY);
|
||||||
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
|
// Elsewhere it joins the group of the row it now sits under (the line
|
||||||
|
// drawn above a header closes the section before it).
|
||||||
|
if (lane && hasGroups) {
|
||||||
|
const rest = sorted.filter((l) => l.id !== lane.id);
|
||||||
|
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
|
||||||
|
const neighbour = rest[at - 1] ?? rest[0];
|
||||||
|
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
|
||||||
|
}
|
||||||
|
reorder((ids) => moveBefore(ids, from, before));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A group no workspace is in yet still shows (to be renamed, or dropped on),
|
||||||
|
// just above the ungrouped ones; a search hides it.
|
||||||
|
const emptyGroups = q
|
||||||
|
? []
|
||||||
|
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
|
||||||
|
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
|
||||||
|
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
|
||||||
|
<GroupHeader
|
||||||
|
key={g?.id ?? "none"}
|
||||||
|
group={g}
|
||||||
|
count={members.length}
|
||||||
|
// What a folded group hides: the most urgent of its workspaces, and
|
||||||
|
// whether the open one is among them.
|
||||||
|
worst={worstOf(members)}
|
||||||
|
holdsSelected={members.some((l) => l.id === selected.lane)}
|
||||||
|
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
|
||||||
|
folded={!!g?.folded && !q}
|
||||||
|
renaming={!!g && renamingGroup === g.id}
|
||||||
|
first={first}
|
||||||
|
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
|
||||||
|
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
|
||||||
|
onRenameEnd={(name) => {
|
||||||
|
setRenamingGroup(null);
|
||||||
|
if (g && name !== null) renameGroup(g.id, name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const newGroup = (laneIds: number[] = []) => {
|
||||||
|
const id = createGroup(t("tree.groupDefaultName"), laneIds);
|
||||||
|
setRenamingGroup(id);
|
||||||
|
};
|
||||||
|
const removeGroup = async (g: WorkspaceGroup) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("tree.groupDelete"),
|
||||||
|
message: t("tree.groupDeleteConfirm", { name: g.name }),
|
||||||
|
confirmLabel: t("tree.groupDelete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
deleteGroup(g.id);
|
||||||
|
};
|
||||||
|
|
||||||
const rename = async (lane: Lane, title: string | null) => {
|
const rename = async (lane: Lane, title: string | null) => {
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
if (title === null || title === (lane.title || "")) return;
|
if (title === null || title === (lane.title || "")) return;
|
||||||
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
|
|||||||
>
|
>
|
||||||
<Search className="h-3.5 w-3.5" />
|
<Search className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="workspace-tree-new-group"
|
||||||
|
onClick={() => newGroup()}
|
||||||
|
aria-label={t("tree.newGroup")}
|
||||||
|
title={t("tree.newGroup")}
|
||||||
|
className={headBtn}
|
||||||
|
>
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="workspace-tree-add"
|
data-testid="workspace-tree-add"
|
||||||
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
|
|||||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||||
|
// The user's own group, inside the owner's.
|
||||||
|
const ug = groupOf(groups, lane.id);
|
||||||
|
const ugKey = ug?.id ?? "";
|
||||||
|
const sameSection = (l: Lane) =>
|
||||||
|
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
|
||||||
|
const prev = sorted[i - 1];
|
||||||
|
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
|
||||||
|
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
|
||||||
|
const sectionFolded = !!ug?.folded && !q;
|
||||||
|
const hiddenRow = folded || sectionFolded;
|
||||||
return (
|
return (
|
||||||
<div key={lane.id} className="relative">
|
<div key={lane.id} className="relative">
|
||||||
{groupStart && groupShown && (
|
{groupStart && groupShown && (
|
||||||
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
|
||||||
|
{sectionStart &&
|
||||||
|
sectionShown &&
|
||||||
|
!folded &&
|
||||||
|
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
|
||||||
{dropAt === i && <DropLine />}
|
{dropAt === i && <DropLine />}
|
||||||
{visible(lane) && !folded && (
|
{visible(lane) && !hiddenRow && (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
lane={lane}
|
lane={lane}
|
||||||
index={i}
|
index={i}
|
||||||
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
|
|||||||
}}
|
}}
|
||||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||||
onNewSession={() => select(lane.id)}
|
onNewSession={() => select(lane.id)}
|
||||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
onReorder={nudge}
|
||||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||||
onRenameStart={() => setRenamingId(lane.id)}
|
onRenameStart={() => setRenamingId(lane.id)}
|
||||||
onRenameEnd={(title) => void rename(lane, title)}
|
onRenameEnd={(title) => void rename(lane, title)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{expanded(lane.id) && !folded && (
|
{expanded(lane.id) && !hiddenRow && (
|
||||||
<WorkspaceSessionList
|
<WorkspaceSessionList
|
||||||
lane={lane}
|
lane={lane}
|
||||||
liveRuns={liveRuns}
|
liveRuns={liveRuns}
|
||||||
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{firstUngrouped === -1 &&
|
||||||
|
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
|
||||||
{dropAt === sorted.length && (
|
{dropAt === sorted.length && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DropLine />
|
<DropLine />
|
||||||
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
|
|||||||
y={menu.y}
|
y={menu.y}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||||
|
groups={groups.groups}
|
||||||
|
groupId={groups.of[menu.lane.id] ?? null}
|
||||||
|
onGroup={(target) => {
|
||||||
|
const id = menu.lane.id;
|
||||||
|
setMenu(null);
|
||||||
|
if (target === "new") newGroup([id]);
|
||||||
|
else assignGroup(id, target);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{groupMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={groupMenu.x}
|
||||||
|
y={groupMenu.y}
|
||||||
|
onClose={() => setGroupMenu(null)}
|
||||||
|
testId={`workspace-group-menu-${groupMenu.group.id}`}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setRenamingGroup(groupMenu.group.id);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupRename")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={groups.groups[0]?.id === groupMenu.group.id}
|
||||||
|
onSelect={() => {
|
||||||
|
moveGroup(groupMenu.group.id, -1);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupMoveUp")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
|
||||||
|
onSelect={() => {
|
||||||
|
moveGroup(groupMenu.group.id, 1);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupMoveDown")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-add"
|
||||||
|
onSelect={() => {
|
||||||
|
const g = groupMenu.group;
|
||||||
|
setGroupMenu(null);
|
||||||
|
openAdd(undefined, g.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupAddWorkspace")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-fold-all"
|
||||||
|
onSelect={() => {
|
||||||
|
setGroupMenu(null);
|
||||||
|
setAllFolded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupFoldAll")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-unfold-all"
|
||||||
|
onSelect={() => {
|
||||||
|
setGroupMenu(null);
|
||||||
|
setAllFolded(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupUnfoldAll")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
testId="workspace-group-delete"
|
||||||
|
onSelect={() => {
|
||||||
|
const g = groupMenu.group;
|
||||||
|
setGroupMenu(null);
|
||||||
|
void removeGroup(g);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupDelete")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<DestructiveLaneModal
|
<DestructiveLaneModal
|
||||||
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
setAddPrefill(null);
|
setAddPrefill(null);
|
||||||
|
setAddToGroup(null);
|
||||||
}}
|
}}
|
||||||
onAdded={(lane) => {
|
onAdded={(lane) => {
|
||||||
|
if (addToGroup) assignGroup(lane.id, addToGroup);
|
||||||
void refreshLanes();
|
void refreshLanes();
|
||||||
select(lane.id);
|
select(lane.id);
|
||||||
}}
|
}}
|
||||||
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a group header's drag carries: the group's id. */
|
||||||
|
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
|
||||||
|
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||||
|
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
|
||||||
|
|
||||||
|
// needsYou outranks an error, an error a running agent; idle/active say nothing.
|
||||||
|
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
|
||||||
|
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
|
||||||
|
function worstOf(lanes: Lane[]): Lane | null {
|
||||||
|
let best: Lane | null = null;
|
||||||
|
let top = 0;
|
||||||
|
for (const l of lanes) {
|
||||||
|
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
|
||||||
|
if (u > top) [best, top] = [l, u];
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
/** A workspace's owner group: its user id, or "none". */
|
/** A workspace's owner group: its user id, or "none". */
|
||||||
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A section's header: a user group (foldable, menu, inline rename) or, with
|
||||||
|
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
|
||||||
|
*/
|
||||||
|
function GroupHeader({
|
||||||
|
group,
|
||||||
|
count,
|
||||||
|
worst,
|
||||||
|
holdsSelected,
|
||||||
|
folded,
|
||||||
|
renaming,
|
||||||
|
first,
|
||||||
|
onToggle,
|
||||||
|
onMenu,
|
||||||
|
onRenameEnd,
|
||||||
|
onGroupDrop,
|
||||||
|
}: {
|
||||||
|
group: WorkspaceGroup | null;
|
||||||
|
count: number;
|
||||||
|
/** The most urgent workspace inside, drawn as its dot while folded. */
|
||||||
|
worst: Lane | null;
|
||||||
|
/** The open workspace is in this group (marked while folded). */
|
||||||
|
holdsSelected: boolean;
|
||||||
|
folded: boolean;
|
||||||
|
renaming: boolean;
|
||||||
|
first: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onMenu: (x: number, y: number) => void;
|
||||||
|
/** The typed name, or null when the edit was cancelled. */
|
||||||
|
onRenameEnd: (name: string | null) => void;
|
||||||
|
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
|
||||||
|
onGroupDrop: (draggedId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const [draft, setDraft] = useState(group?.name ?? "");
|
||||||
|
const [dropOver, setDropOver] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (renaming) setDraft(group?.name ?? "");
|
||||||
|
}, [renaming, group?.name]);
|
||||||
|
const label = group ? group.name : t("tree.ungrouped");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-group-drop={group?.id ?? ""}
|
||||||
|
data-testid={`workspace-group-${group?.id ?? "none"}`}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (!group) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
// A group (never "Ungrouped") is dragged by its header to reorder.
|
||||||
|
draggable={!!group && !renaming}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (!group) return;
|
||||||
|
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!isGroupDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setDropOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDropOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
setDropOver(false);
|
||||||
|
if (!isGroupDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
|
||||||
|
if (id) onGroupDrop(id);
|
||||||
|
}}
|
||||||
|
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
|
||||||
|
>
|
||||||
|
{renaming && group ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
maxLength={60}
|
||||||
|
aria-label={t("tree.groupNameLabel")}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onBlur={() => onRenameEnd(draft)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onRenameEnd(draft);
|
||||||
|
else if (e.key === "Escape") onRenameEnd(null);
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={group ? !folded : undefined}
|
||||||
|
disabled={!group}
|
||||||
|
title={group ? t("tree.groupToggle", { name: label }) : undefined}
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
|
||||||
|
>
|
||||||
|
{group ? (
|
||||||
|
folded ? (
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
|
||||||
|
{folded && worst && (
|
||||||
|
<span
|
||||||
|
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
|
||||||
|
className="ml-1 flex shrink-0 items-center"
|
||||||
|
>
|
||||||
|
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
|
||||||
|
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{group && !renaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("tree.groupMenu")}
|
||||||
|
title={t("tree.groupMenu")}
|
||||||
|
onClick={(e) => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
onMenu(r.left, r.bottom);
|
||||||
|
}}
|
||||||
|
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||||
function DropLine() {
|
function DropLine() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
|
|||||||
import type { Agent } from "../../lib/types";
|
import type { Agent } from "../../lib/types";
|
||||||
import { formatModelName, fmtCost } from "../../lib/format";
|
import { formatModelName, fmtCost } from "../../lib/format";
|
||||||
|
|
||||||
function renderCard(element: JSX.Element) {
|
function renderCard(element: React.JSX.Element) {
|
||||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
|
|||||||
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
||||||
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
||||||
expect(titleFor("/settings")).toMatch(/^Settings · /);
|
expect(titleFor("/settings")).toMatch(/^Settings · /);
|
||||||
|
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
|
||||||
|
expect(titleFor("/files")).toMatch(/^Files · /);
|
||||||
|
expect(titleFor("/admin")).toMatch(/^Administration · /);
|
||||||
|
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("titles an unknown route as not found", () => {
|
||||||
|
expect(titleFor("/nope")).toMatch(/^Not found · /);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { api } from "../../lib/api";
|
|||||||
import { StatusBar } from "../StatusBar";
|
import { StatusBar } from "../StatusBar";
|
||||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||||
|
|
||||||
const at = (url: string, ui: JSX.Element) =>
|
const at = (url: string, ui: React.JSX.Element) =>
|
||||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||||
|
|
||||||
describe("StatusBar", () => {
|
describe("StatusBar", () => {
|
||||||
@@ -162,7 +162,7 @@ describe("StatusBar", () => {
|
|||||||
const conn = screen.getByRole("button", { name: /Live/ });
|
const conn = screen.getByRole("button", { name: /Live/ });
|
||||||
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
||||||
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
|
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
|
||||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
|
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
|
||||||
expect(cost).toHaveAttribute(
|
expect(cost).toHaveAttribute(
|
||||||
"title",
|
"title",
|
||||||
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
||||||
@@ -238,7 +238,7 @@ describe("StatusBar", () => {
|
|||||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||||
expect(work).toHaveTextContent("Week32%");
|
expect(work).toHaveTextContent("Week32%");
|
||||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
expect(personal).toHaveTextContent("Check quota in Settings → AI tools → Profiles");
|
||||||
expect(personal).not.toHaveTextContent("in use");
|
expect(personal).not.toHaveTextContent("in use");
|
||||||
expect(codex).toHaveTextContent("dev@example.com");
|
expect(codex).toHaveTextContent("dev@example.com");
|
||||||
expect(codex).toHaveTextContent("Sign-in expired");
|
expect(codex).toHaveTextContent("Sign-in expired");
|
||||||
|
|||||||
@@ -298,12 +298,102 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
|||||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores Escape while an update is applying", async () => {
|
it("closes while an update applies, and re-opens if it then fails", async () => {
|
||||||
applyMock.mockReturnValue(new Promise(() => {}));
|
let fail: (e: Error) => void = () => {};
|
||||||
|
applyMock.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||||
render(<UpdateNotifier />);
|
render(<UpdateNotifier />);
|
||||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||||
await userEvent.keyboard("{Escape}");
|
await userEvent.keyboard("{Escape}");
|
||||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
fail(new Error("Command failed: npm run build"));
|
||||||
|
expect(await screen.findByText("Command failed: npm run build")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateNotifier — hidden while it runs", () => {
|
||||||
|
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
|
||||||
|
const PLUGIN: UpdateStatusPayload = {
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "plugin_managed",
|
||||||
|
install_kind: "plugin",
|
||||||
|
plugin_marketplace: "sg",
|
||||||
|
plugin_name: "agenthub",
|
||||||
|
commits_behind: 0,
|
||||||
|
local_version: "1.5.0",
|
||||||
|
release_check: "ok",
|
||||||
|
latest_release: "v1.6.0",
|
||||||
|
};
|
||||||
|
statusMock.mockResolvedValue(PLUGIN);
|
||||||
|
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||||
|
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...PLUGIN,
|
||||||
|
plugin_update_progress: {
|
||||||
|
step: "plugin_update",
|
||||||
|
index: 2,
|
||||||
|
total: 3,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
|
||||||
|
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
const chip = await screen.findByTestId("update-chip");
|
||||||
|
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
|
||||||
|
expect(chip).toHaveTextContent(step);
|
||||||
|
await userEvent.click(chip);
|
||||||
|
expect(
|
||||||
|
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
|
||||||
|
const running: UpdateStatusPayload = {
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "plugin_managed",
|
||||||
|
install_kind: "plugin",
|
||||||
|
plugin_marketplace: "sg",
|
||||||
|
plugin_name: "agenthub",
|
||||||
|
commits_behind: 0,
|
||||||
|
local_version: "1.5.0",
|
||||||
|
release_check: "ok",
|
||||||
|
latest_release: "v1.6.0",
|
||||||
|
// The new version is already installed: the status no longer offers it.
|
||||||
|
update_available: false,
|
||||||
|
plugin_update_progress: {
|
||||||
|
step: "bootstrap",
|
||||||
|
index: 3,
|
||||||
|
total: 3,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
statusMock.mockResolvedValue(running);
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
|
||||||
|
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||||
|
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
const chip = await screen.findByTestId("update-chip");
|
||||||
|
expect(chip).toHaveTextContent(step);
|
||||||
|
await userEvent.click(chip);
|
||||||
|
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no chip when nothing is running", async () => {
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await screen.findByText(i18n.t("updates:title"));
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||||
* selection
|
* selection
|
||||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||||
* search, the row menu's items by kind and tool, and the status dot.
|
* search, the row menu's items by kind and tool, the status dot, and the
|
||||||
|
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
|
||||||
|
* state dot, dragging a header to reorder, fold all).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -91,6 +93,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
|
||||||
|
vi.mock("../lanes/AddLaneModal", () => ({
|
||||||
|
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="stub-add-lane"
|
||||||
|
onClick={() => {
|
||||||
|
onAdded(lane(2, "/r/sub"));
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { WorkspaceTree } from "../WorkspaceTree";
|
import { WorkspaceTree } from "../WorkspaceTree";
|
||||||
import { workspaceDot } from "../lanes/WorkspaceRow";
|
import { workspaceDot } from "../lanes/WorkspaceRow";
|
||||||
@@ -576,6 +592,185 @@ describe("WorkspaceTree", () => {
|
|||||||
menu = screen.getByTestId("workspace-row-menu-3");
|
menu = screen.getByTestId("workspace-row-menu-3");
|
||||||
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-row-1");
|
||||||
|
// No group yet: no headers at all.
|
||||||
|
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||||
|
|
||||||
|
// A new group from the row menu holds that workspace and asks for a name.
|
||||||
|
await user.click(screen.getByTestId("workspace-menu-3"));
|
||||||
|
await user.click(screen.getByTestId("workspace-move-group-new"));
|
||||||
|
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||||
|
await user.clear(name);
|
||||||
|
await user.type(name, "Backend{Enter}");
|
||||||
|
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||||
|
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
|
||||||
|
const gid = stored.groups[0].id as string;
|
||||||
|
expect(stored.of).toEqual({ 3: gid });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
|
||||||
|
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
|
||||||
|
|
||||||
|
// Another workspace joins it from its menu.
|
||||||
|
await user.click(screen.getByTestId("workspace-menu-2"));
|
||||||
|
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Folding hides its rows; the ungrouped one stays.
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
|
||||||
|
expanded: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1"]);
|
||||||
|
|
||||||
|
// Deleting the group only ungroups: every workspace is still listed.
|
||||||
|
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-delete"));
|
||||||
|
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||||
|
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||||
|
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-row-1");
|
||||||
|
await user.click(screen.getByTestId("workspace-tree-new-group"));
|
||||||
|
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||||
|
await user.clear(name);
|
||||||
|
await user.type(name, "Later{Enter}");
|
||||||
|
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
|
||||||
|
const header = screen.getByTestId(`workspace-group-${gid}`);
|
||||||
|
expect(header).toHaveTextContent("Later0");
|
||||||
|
expect(
|
||||||
|
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(rowIds()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceTree groups", () => {
|
||||||
|
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
|
||||||
|
const seed = (extra: { folded?: boolean } = {}) =>
|
||||||
|
localStorage.setItem(
|
||||||
|
"agenthub.workspace.groups",
|
||||||
|
JSON.stringify({
|
||||||
|
groups: [
|
||||||
|
{ id: "ga", name: "Alpha", ...extra },
|
||||||
|
{ id: "gb", name: "Beta" },
|
||||||
|
],
|
||||||
|
of: { 1: "ga", 3: "gb" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||||
|
|
||||||
|
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
// The last of Alpha, Alt+↓: the first of Beta.
|
||||||
|
screen.getByTestId("workspace-open-1").focus();
|
||||||
|
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||||
|
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
|
||||||
|
// The last of Beta, Alt+↓: out into the ungrouped ones.
|
||||||
|
screen.getByTestId("workspace-open-3").focus();
|
||||||
|
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||||
|
expect(stored().of).toEqual({ 1: "gb" });
|
||||||
|
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a folded group's most urgent state and marks the open workspace", async () => {
|
||||||
|
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
|
||||||
|
seed({ folded: true });
|
||||||
|
renderTree("/workspace?lane=1");
|
||||||
|
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
|
||||||
|
const state = screen.getByTestId("workspace-group-state-ga");
|
||||||
|
expect(state).toHaveTextContent("running");
|
||||||
|
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
|
||||||
|
"text-accent"
|
||||||
|
);
|
||||||
|
// An open group shows its rows instead.
|
||||||
|
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
|
||||||
|
seed();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-gb");
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
const dataTransfer = {
|
||||||
|
get types() {
|
||||||
|
return [...data.keys()];
|
||||||
|
},
|
||||||
|
setData: (k: string, v: string) => data.set(k, v),
|
||||||
|
getData: (k: string) => data.get(k) ?? "",
|
||||||
|
dropEffect: "move",
|
||||||
|
effectAllowed: "move",
|
||||||
|
};
|
||||||
|
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||||
|
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||||
|
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||||
|
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
data.clear();
|
||||||
|
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||||
|
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
|
||||||
|
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
|
||||||
|
// A header drag never moves a workspace.
|
||||||
|
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a workspace added from a group's menu into that group", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-gb");
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-add"));
|
||||||
|
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||||
|
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
|
||||||
|
// The header's own Add belongs to no group: what it adds stays where it is.
|
||||||
|
act(() =>
|
||||||
|
localStorage.setItem(
|
||||||
|
"agenthub.workspace.groups",
|
||||||
|
JSON.stringify({ ...stored(), of: { 1: "ga" } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await user.click(screen.getByTestId("workspace-tree-add"));
|
||||||
|
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||||
|
expect(stored().of).toEqual({ 1: "ga" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds and unfolds every group from a header's menu", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-ga");
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-fold-all"));
|
||||||
|
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-unfold-all"));
|
||||||
|
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workspaceDot", () => {
|
describe("workspaceDot", () => {
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* @file MiniChart.tsx
|
||||||
|
* @description The Administration page's small single-series charts: a bar
|
||||||
|
* chart (a user's daily spend) and a line chart (System Health's CPU and
|
||||||
|
* memory history), plain SVG in the app's theme tokens. One series each — the
|
||||||
|
* card's title names it, so there is no legend — thin marks, rounded bar ends
|
||||||
|
* on the baseline, a recessive grid with a max label, and a hover tooltip
|
||||||
|
* (crosshair for the line, per bar for the bars) whose text is the caller's.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRef, useState, type MouseEvent } from "react";
|
||||||
|
|
||||||
|
export type ChartPoint = { x: string; y: number };
|
||||||
|
|
||||||
|
const H = 120;
|
||||||
|
const PAD_TOP = 14;
|
||||||
|
const PAD_BOTTOM = 18;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param props.points - In x order; `x` is the axis/tooltip label.
|
||||||
|
* @param props.kind - `bar` or `line`.
|
||||||
|
* @param props.format - Formats a value for the max label and the tooltip.
|
||||||
|
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
|
||||||
|
* @param props.label - The chart's accessible name.
|
||||||
|
*/
|
||||||
|
export function MiniChart({
|
||||||
|
points,
|
||||||
|
kind,
|
||||||
|
format,
|
||||||
|
max,
|
||||||
|
label,
|
||||||
|
emptyText,
|
||||||
|
}: {
|
||||||
|
points: ChartPoint[];
|
||||||
|
kind: "bar" | "line";
|
||||||
|
format: (v: number) => string;
|
||||||
|
max?: number;
|
||||||
|
label: string;
|
||||||
|
emptyText?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<SVGSVGElement>(null);
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
if (points.length === 0)
|
||||||
|
return (
|
||||||
|
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
|
||||||
|
{emptyText ?? "—"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const W = 600;
|
||||||
|
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
|
||||||
|
const plotH = H - PAD_TOP - PAD_BOTTOM;
|
||||||
|
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
|
||||||
|
const step = W / points.length;
|
||||||
|
const xOf = (i: number) =>
|
||||||
|
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||||
|
const r = ref.current?.getBoundingClientRect();
|
||||||
|
if (!r || r.width === 0) return;
|
||||||
|
const fx = ((e.clientX - r.left) / r.width) * W;
|
||||||
|
const i =
|
||||||
|
kind === "bar"
|
||||||
|
? Math.floor(fx / step)
|
||||||
|
: Math.round((fx / W) * Math.max(1, points.length - 1));
|
||||||
|
setHover(Math.max(0, Math.min(points.length - 1, i)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const h = hover != null ? points[hover] : null;
|
||||||
|
const barW = Math.max(2, Math.min(24, step - 2));
|
||||||
|
const line = points
|
||||||
|
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="block h-[120px] w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
onMouseMove={onMove}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
{[0, 0.5, 1].map((f) => (
|
||||||
|
<line
|
||||||
|
key={f}
|
||||||
|
x1={0}
|
||||||
|
x2={W}
|
||||||
|
y1={yOf(top * f)}
|
||||||
|
y2={yOf(top * f)}
|
||||||
|
className="stroke-border"
|
||||||
|
strokeWidth={1}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
strokeDasharray={f === 0 ? undefined : "3 3"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{kind === "bar" ? (
|
||||||
|
points.map((p, i) => {
|
||||||
|
const y = yOf(p.y);
|
||||||
|
const bh = Math.max(0, PAD_TOP + plotH - y);
|
||||||
|
const r = Math.min(4, barW / 2, bh);
|
||||||
|
const x = xOf(i) - barW / 2;
|
||||||
|
const base = PAD_TOP + plotH;
|
||||||
|
// Rounded top only; the base sits flat on the axis.
|
||||||
|
const d =
|
||||||
|
bh <= 0
|
||||||
|
? ""
|
||||||
|
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
|
||||||
|
return (
|
||||||
|
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
|
||||||
|
className="fill-accent-solid"
|
||||||
|
opacity={0.12}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={line}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-accent-solid"
|
||||||
|
strokeWidth={2}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{hover != null && (
|
||||||
|
<line
|
||||||
|
x1={xOf(hover)}
|
||||||
|
x2={xOf(hover)}
|
||||||
|
y1={PAD_TOP}
|
||||||
|
y2={PAD_TOP + plotH}
|
||||||
|
className="stroke-fg-muted"
|
||||||
|
strokeWidth={1}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
|
||||||
|
{format(top)}
|
||||||
|
</span>
|
||||||
|
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
|
||||||
|
<span>{points[0]!.x}</span>
|
||||||
|
<span>{points[points.length - 1]!.x}</span>
|
||||||
|
</div>
|
||||||
|
{h && hover != null && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
|
||||||
|
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div className="text-fg-muted">{h.x}</div>
|
||||||
|
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file ServerPanel.tsx
|
|
||||||
* @description The Administration page's Server tab: the machine AgentHub
|
|
||||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
|
||||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
|
||||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
|
||||||
* machine, resident memory, process count, the busiest processes by name, and
|
|
||||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
|
||||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
|
||||||
* at once. Read-only — the only action here is the members' tmux card
|
|
||||||
* (`MemberTmuxCard`, an admin's restart).
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Cpu,
|
|
||||||
HardDrive,
|
|
||||||
MemoryStick,
|
|
||||||
MonitorCog,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
type HostStats,
|
|
||||||
type HostUsageGroup,
|
|
||||||
type ServerUserUsage,
|
|
||||||
type UserStorage,
|
|
||||||
} from "../../lib/api";
|
|
||||||
import { formatBytes } from "../../lib/format";
|
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
|
||||||
import { StatCard } from "../StatCard";
|
|
||||||
import { UserAvatar } from "../users/UserAvatar";
|
|
||||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
|
||||||
|
|
||||||
const POLL_MS = 5000;
|
|
||||||
|
|
||||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
|
||||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
|
||||||
|
|
||||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
|
||||||
function Meter({ value, label }: { value: number; label: string }) {
|
|
||||||
const v = Math.max(0, Math.min(100, value));
|
|
||||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="meter"
|
|
||||||
aria-label={label}
|
|
||||||
aria-valuenow={Math.round(v)}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
|
||||||
const d = Math.floor(sec / 86400);
|
|
||||||
const h = Math.floor((sec % 86400) / 3600);
|
|
||||||
const m = Math.floor((sec % 3600) / 60);
|
|
||||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
|
||||||
const { t } = useTranslation("admin");
|
|
||||||
const [stats, setStats] = useState<HostStats | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
|
||||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
|
||||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const load = useCallback(
|
|
||||||
() =>
|
|
||||||
api.host
|
|
||||||
.stats()
|
|
||||||
.then((s) => {
|
|
||||||
setStats(s);
|
|
||||||
setError(null);
|
|
||||||
})
|
|
||||||
.catch((e: Error) => setError(e.message)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [spinning, refresh] = useSpin(load);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
const id = setInterval(() => {
|
|
||||||
if (document.visibilityState === "visible") void load();
|
|
||||||
}, POLL_MS);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
// Names, pictures and home sizes: read once, they change slowly.
|
|
||||||
useEffect(() => {
|
|
||||||
api.users
|
|
||||||
.list()
|
|
||||||
.then((r) => setUsers(r.users))
|
|
||||||
.catch(() => setUsers([]));
|
|
||||||
api.users
|
|
||||||
.storage()
|
|
||||||
.then((r) => setStorage(r.storage))
|
|
||||||
.catch(() => setStorage({}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
|
||||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
|
||||||
const loading = !stats && !error;
|
|
||||||
const mem = stats?.memory;
|
|
||||||
const toggle = (key: string) =>
|
|
||||||
setOpen((s) => {
|
|
||||||
const n = new Set(s);
|
|
||||||
if (n.has(key)) n.delete(key);
|
|
||||||
else n.add(key);
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupName = (g: HostUsageGroup) => {
|
|
||||||
if (g.kind === "host") return t("server.hostGroup");
|
|
||||||
if (g.kind === "system") return t("server.systemGroup");
|
|
||||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
|
||||||
return u?.displayName || g.username || "";
|
|
||||||
};
|
|
||||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6" data-testid="server-panel">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
|
||||||
<Server className="w-4 h-4 text-accent" />
|
|
||||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
|
||||||
</h2>
|
|
||||||
{stats && (
|
|
||||||
<p className="mt-0.5 text-xs text-fg-muted">
|
|
||||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
|
||||||
{" · "}
|
|
||||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={spinning}
|
|
||||||
aria-busy={spinning}
|
|
||||||
className="btn-ghost"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
|
||||||
{t("common:refresh")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
|
||||||
>
|
|
||||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span className="flex-1 min-w-0">{error}</span>
|
|
||||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
|
||||||
{t("common:retry")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<StatCard
|
|
||||||
label={t("server.cpu")}
|
|
||||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
|
||||||
icon={Cpu}
|
|
||||||
trend={
|
|
||||||
stats
|
|
||||||
? t("server.cores", {
|
|
||||||
n: stats.cpu.cores,
|
|
||||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
raw={stats?.cpu.model ?? undefined}
|
|
||||||
help={t("server.cpuHelp")}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label={t("server.memory")}
|
|
||||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
|
||||||
icon={MemoryStick}
|
|
||||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label={t("server.swap")}
|
|
||||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
|
||||||
icon={MonitorCog}
|
|
||||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label={t("server.homesDisk")}
|
|
||||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
|
||||||
icon={HardDrive}
|
|
||||||
trend={
|
|
||||||
homesDisk
|
|
||||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
help={t("server.homesDiskHelp")}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card overflow-x-auto">
|
|
||||||
<div className="px-5 pt-4 pb-2">
|
|
||||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{stats && !stats.usage ? (
|
|
||||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
|
||||||
) : (
|
|
||||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-left">
|
|
||||||
<th className={th}>{t("server.who")}</th>
|
|
||||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
|
||||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
|
||||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
|
||||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
|
||||||
{t("server.sampling")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{stats?.usage?.groups.map((g) => {
|
|
||||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
|
||||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
|
||||||
const isOpen = open.has(g.key);
|
|
||||||
const name = groupName(g);
|
|
||||||
return (
|
|
||||||
<Fragment key={g.key}>
|
|
||||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggle(g.key)}
|
|
||||||
disabled={!g.top.length}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
|
||||||
>
|
|
||||||
{g.top.length ? (
|
|
||||||
isOpen ? (
|
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="w-3.5" />
|
|
||||||
)}
|
|
||||||
{g.kind === "user" ? (
|
|
||||||
<UserAvatar
|
|
||||||
name={name}
|
|
||||||
avatarAt={u?.avatarAt}
|
|
||||||
userId={g.userId}
|
|
||||||
className="h-6 w-6 text-[10px]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
|
||||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
|
||||||
{g.kind === "user" && u?.displayName && (
|
|
||||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
|
||||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<div className="text-sm text-fg-primary tabular-nums">
|
|
||||||
{formatBytes(g.memory)}{" "}
|
|
||||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
|
||||||
</div>
|
|
||||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
|
||||||
{g.processes}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
{home ? (
|
|
||||||
<>
|
|
||||||
<div className="text-sm text-fg-primary tabular-nums">
|
|
||||||
{formatBytes(home.bytes)}
|
|
||||||
{homesDisk && (
|
|
||||||
<span className="text-fg-muted">
|
|
||||||
{" "}
|
|
||||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{homesDisk && (
|
|
||||||
<Meter
|
|
||||||
value={pct(home.bytes, homesDisk.total)}
|
|
||||||
label={`${name} ${t("server.home")}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-fg-muted">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{isOpen &&
|
|
||||||
g.top.map((p) => (
|
|
||||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
|
||||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
|
||||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
|
||||||
{fmtPct(p.cpu)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
|
||||||
{formatBytes(p.memory)}
|
|
||||||
</td>
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats && stats.disks.length > 0 && (
|
|
||||||
<div className="card overflow-x-auto">
|
|
||||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
|
||||||
{t("server.disks")}
|
|
||||||
</h3>
|
|
||||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-left">
|
|
||||||
<th className={th}>{t("server.mount")}</th>
|
|
||||||
<th className={th}>{t("server.device")}</th>
|
|
||||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
|
||||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{stats.disks.map((d) => (
|
|
||||||
<tr key={d.device}>
|
|
||||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
|
||||||
<span className="font-mono">{d.mount}</span>
|
|
||||||
{d.mount === stats.homesMount && (
|
|
||||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
|
||||||
<span className="font-mono">{d.device}</span> · {d.type}
|
|
||||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<div className="text-sm text-fg-primary tabular-nums">
|
|
||||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
|
||||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
|
||||||
</div>
|
|
||||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
|
||||||
{formatBytes(d.free)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stats && stats.gpus.length > 0 && (
|
|
||||||
<div className="card p-5 space-y-3">
|
|
||||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
|
||||||
{stats.gpus.map((g, i) => (
|
|
||||||
<div key={i} className="space-y-1">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-fg-primary">{g.name}</span>
|
|
||||||
<span className="text-fg-muted tabular-nums">
|
|
||||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
|
||||||
{formatBytes(g.memoryTotal)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Meter value={g.utilization} label={g.name} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MemberTmuxCard isAdmin={isAdmin} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
|
|||||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||||
import { throttle } from "../../lib/throttle";
|
import { throttle } from "../../lib/throttle";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
import type {
|
||||||
|
TranscriptMessage,
|
||||||
|
TranscriptInfo,
|
||||||
|
TranscriptResult,
|
||||||
|
WSMessage,
|
||||||
|
} from "../../lib/types";
|
||||||
|
|
||||||
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
||||||
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
||||||
@@ -175,6 +180,8 @@ function ConversationViewPane({
|
|||||||
initialTranscriptId ?? null
|
initialTranscriptId ?? null
|
||||||
);
|
);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
|
||||||
|
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||||
@@ -283,6 +290,7 @@ function ConversationViewPane({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setNotStarted(false);
|
setNotStarted(false);
|
||||||
setMessages(result.messages);
|
setMessages(result.messages);
|
||||||
|
setLeadEdits(result.turn_edits);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setHasMore(result.has_more);
|
setHasMore(result.has_more);
|
||||||
lastLineRef.current = result.last_line;
|
lastLineRef.current = result.last_line;
|
||||||
@@ -343,6 +351,7 @@ function ConversationViewPane({
|
|||||||
// Seed the view in a single render so the user sees the whole
|
// Seed the view in a single render so the user sees the whole
|
||||||
// catch-up batch instead of a blank panel followed by a partial one.
|
// catch-up batch instead of a blank panel followed by a partial one.
|
||||||
setMessages(result.messages);
|
setMessages(result.messages);
|
||||||
|
setLeadEdits(result.turn_edits);
|
||||||
firstLineRef.current = result.first_line;
|
firstLineRef.current = result.first_line;
|
||||||
setHasMore(result.has_more);
|
setHasMore(result.has_more);
|
||||||
} else {
|
} else {
|
||||||
@@ -487,6 +496,7 @@ function ConversationViewPane({
|
|||||||
firstLineRef.current = result.first_line;
|
firstLineRef.current = result.first_line;
|
||||||
|
|
||||||
setMessages((prev) => [...result.messages, ...prev]);
|
setMessages((prev) => [...result.messages, ...prev]);
|
||||||
|
setLeadEdits(result.turn_edits);
|
||||||
setHasMore(result.has_more);
|
setHasMore(result.has_more);
|
||||||
|
|
||||||
// Preserve scroll position (don't jump to top)
|
// Preserve scroll position (don't jump to top)
|
||||||
@@ -686,6 +696,7 @@ function ConversationViewPane({
|
|||||||
stopped={stopped}
|
stopped={stopped}
|
||||||
agentLinks={agentLinks}
|
agentLinks={agentLinks}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
leadEdits={leadEdits}
|
||||||
sessionId={
|
sessionId={
|
||||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ interface MessageListProps {
|
|||||||
/** A subagent's conversation inside its step: tight spacing, and the task
|
/** A subagent's conversation inside its step: tight spacing, and the task
|
||||||
* the orchestrator gave it reads as the prompt it is. */
|
* the orchestrator gave it reads as the prompt it is. */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** The files the first, cut-off turn changed before `messages` begins (the
|
||||||
|
* transcript page's `turn_edits`): counted in that turn's card. */
|
||||||
|
leadEdits?: TranscriptContent["edits"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a map from tool_use id → tool_result for matching */
|
/** Build a map from tool_use id → tool_result for matching */
|
||||||
@@ -491,6 +494,10 @@ type Item =
|
|||||||
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
||||||
|
|
||||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||||
|
// A subagent's result carries the files IT changed (`edits`): they count too.
|
||||||
|
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||||
|
// The stand-in for the part of a turn the page cut off: its `edits` count.
|
||||||
|
const LEAD = "__lead_edits";
|
||||||
|
|
||||||
/** A line that is only Claude Code's local-command plumbing — the caveat it
|
/** A line that is only Claude Code's local-command plumbing — the caveat it
|
||||||
* writes first, the command, what the command printed — or null for
|
* writes first, the command, what the command printed — or null for
|
||||||
@@ -515,11 +522,18 @@ export function localCommand(
|
|||||||
return { name, output, error };
|
return { name, output, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
export function chatItems(
|
||||||
|
messages: TranscriptMessage[],
|
||||||
|
keys: string[],
|
||||||
|
leadEdits?: TranscriptContent["edits"]
|
||||||
|
): Item[] {
|
||||||
const items: Item[] = [];
|
const items: Item[] = [];
|
||||||
// The file-changing calls of the turn so far, closed into one card when the
|
// The file-changing calls of the turn so far, closed into one card when the
|
||||||
// human speaks again (or the list ends).
|
// human speaks again (or the list ends) — starting with what the page's
|
||||||
let edits: TranscriptContent[] = [];
|
// cut-off first turn changed before it.
|
||||||
|
let edits: TranscriptContent[] = leadEdits?.length
|
||||||
|
? [{ type: "tool_use", name: LEAD, edits: leadEdits }]
|
||||||
|
: [];
|
||||||
// The turn's prompt time, its newest assistant line and its output so far,
|
// The turn's prompt time, its newest assistant line and its output so far,
|
||||||
// for the "Done in …" line under a turn that ended.
|
// for the "Done in …" line under a turn that ended.
|
||||||
let start: string | null = null;
|
let start: string | null = null;
|
||||||
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
|||||||
if (msg.usage) output.add(msg.usage);
|
if (msg.usage) output.add(msg.usage);
|
||||||
msg.content.forEach((block, b) => {
|
msg.content.forEach((block, b) => {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
if (
|
||||||
|
block.type === "tool_use" &&
|
||||||
|
(EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? ""))
|
||||||
|
)
|
||||||
|
edits.push(block);
|
||||||
// A question to the user is a turn of the conversation, not hidden work.
|
// A question to the user is a turn of the conversation, not hidden work.
|
||||||
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||||
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
||||||
@@ -672,19 +690,38 @@ function EditedFilesCard({
|
|||||||
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||||
// Written whole in this turn (a Write): a component made here is an artifact.
|
// Written whole in this turn (a Write): a component made here is an artifact.
|
||||||
const created = new Set<string>();
|
const created = new Set<string>();
|
||||||
|
// Changed only by a subagent: listed, never an artifact card — the chat
|
||||||
|
// rebuilds an artifact from this transcript's Write/Edit inputs, which a
|
||||||
|
// subagent's are not.
|
||||||
|
const byAgent = new Set<string>();
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
|
const r0 = b.id ? toolResultMap.get(b.id) : undefined;
|
||||||
|
if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) {
|
||||||
|
for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) {
|
||||||
|
if (!files.has(e.path)) byAgent.add(e.path);
|
||||||
|
const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false };
|
||||||
|
f.added += e.added;
|
||||||
|
f.removed += e.removed;
|
||||||
|
f.counted ||= e.counted;
|
||||||
|
files.set(e.path, f);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||||
const p = input.file_path ?? input.notebook_path;
|
const p = input.file_path ?? input.notebook_path;
|
||||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||||
if (b.name === "Write") created.add(p);
|
if (b.name === "Write") created.add(p);
|
||||||
|
byAgent.delete(p);
|
||||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||||
f.added += r.diff?.added ?? 0;
|
f.added += r.diff?.added ?? 0;
|
||||||
f.removed += r.diff?.removed ?? 0;
|
f.removed += r.diff?.removed ?? 0;
|
||||||
f.counted ||= !!r.diff;
|
f.counted ||= !!r.diff;
|
||||||
files.set(p, f);
|
files.set(p, f);
|
||||||
}
|
}
|
||||||
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
|
const artifacts = [...files.keys()].filter(
|
||||||
|
(p) => !byAgent.has(p) && isArtifact(p, created.has(p))
|
||||||
|
);
|
||||||
const cards = artifacts.length > 0 && (
|
const cards = artifacts.length > 0 && (
|
||||||
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
||||||
{artifacts.map((p) => (
|
{artifacts.map((p) => (
|
||||||
@@ -773,6 +810,7 @@ function MessageListView({
|
|||||||
sessionId,
|
sessionId,
|
||||||
agentLinks,
|
agentLinks,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
leadEdits,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||||
@@ -798,9 +836,10 @@ function MessageListView({
|
|||||||
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
|
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
|
||||||
)
|
)
|
||||||
: messages,
|
: messages,
|
||||||
keys
|
keys,
|
||||||
|
leadEdits
|
||||||
),
|
),
|
||||||
[messages, keys, compact]
|
[messages, keys, compact, leadEdits]
|
||||||
);
|
);
|
||||||
const agents = useMemo(
|
const agents = useMemo(
|
||||||
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||||
* removed summed over the turn, a failed call left out, a row click handed
|
* removed summed over the turn, a failed call left out, a row click handed
|
||||||
* to the caller, and a row (or the header, every file) dragged as `@path`
|
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||||
* attachments for a prompt box.
|
* attachments for a prompt box. Files a subagent changed (its Agent
|
||||||
|
* result's `edits`, read from its own transcript) are counted in the same
|
||||||
|
* card, never as an artifact; so are the files a turn changed before the
|
||||||
|
* loaded page began (`leadEdits`, the page's `turn_edits`).
|
||||||
* @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 } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
@@ -127,4 +130,71 @@ describe("the Edited N files card", () => {
|
|||||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts the files a subagent changed, beside the turn's own edits", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[
|
||||||
|
user("run the task"),
|
||||||
|
call("1", "Edit", "/w/a.go"),
|
||||||
|
result("1", { added: 1, removed: 0 }),
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
|
||||||
|
} as TranscriptMessage,
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sender: "tool",
|
||||||
|
timestamp: at,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id: "2",
|
||||||
|
output: "done",
|
||||||
|
agent: { id: "x1" },
|
||||||
|
edits: [
|
||||||
|
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
|
||||||
|
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
|
||||||
|
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as TranscriptMessage,
|
||||||
|
user("thanks"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const card = screen.getByTestId("edited-files");
|
||||||
|
expect(card.textContent).toContain("Edited 3 files");
|
||||||
|
expect(within(card).getByText("b_test.go")).toBeTruthy();
|
||||||
|
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
|
||||||
|
// A page only a subagent wrote is listed, not shown as an artifact card.
|
||||||
|
expect(within(card).getByText("report.html")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("artifact-card")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
|
||||||
|
messages={[
|
||||||
|
call("1", "Write", "/w/handler_test.go"),
|
||||||
|
result("1", { added: 113, removed: 0 }),
|
||||||
|
user("what changed?"),
|
||||||
|
call("2", "Edit", "/w/x.go"),
|
||||||
|
result("2", { added: 1, removed: 0 }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const [first, second] = screen.getAllByTestId("edited-files");
|
||||||
|
expect(first!.textContent).toContain("Edited 2 files");
|
||||||
|
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
|
||||||
|
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
|
||||||
|
// The next turn's card is its own.
|
||||||
|
expect(second!.textContent).toContain("Edited 1 file");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Copy, Check } from "lucide-react";
|
import { Copy, Check } from "lucide-react";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
|
||||||
// ───────────────────────── Copy button ─────────────────────────
|
// ───────────────────────── Copy button ─────────────────────────
|
||||||
|
|
||||||
@@ -111,13 +112,9 @@ export function CopyButton({ text }: { text: string }) {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
try {
|
if (!(await copyText(text, true))) return;
|
||||||
await navigator.clipboard.writeText(text);
|
setCopied(true);
|
||||||
setCopied(true);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
} catch {
|
|
||||||
// Clipboard API can fail in insecure contexts - silently ignore.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
/**
|
||||||
|
* @file HostHealth.tsx
|
||||||
|
* @description The machine AgentHub runs on, as part of the Dashboard's
|
||||||
|
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
|
||||||
|
* signed-in user sees all of it). In the tab's own card style:
|
||||||
|
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
|
||||||
|
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
|
||||||
|
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
|
||||||
|
* CPU / memory history (`GET /api/host/history`, kept in memory since the
|
||||||
|
* server started); then CPU (every thread as a compact cell, temperatures,
|
||||||
|
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||||
|
* busiest processes with who each counts for (history, CPU threads and
|
||||||
|
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||||
|
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
||||||
|
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||||
|
* Administration users table shares for each home's share of the homes disk.
|
||||||
|
* Read-only.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
ListTree,
|
||||||
|
Network,
|
||||||
|
Server,
|
||||||
|
Thermometer,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type HostHistory,
|
||||||
|
type HostHistoryRange,
|
||||||
|
type HostStats,
|
||||||
|
type HostUsageGroup,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||||
|
import { Tip } from "../Tip";
|
||||||
|
import { MiniChart } from "../admin/MiniChart";
|
||||||
|
import { UserAvatar } from "../users/UserAvatar";
|
||||||
|
|
||||||
|
const POLL_MS = 5000;
|
||||||
|
const HISTORY_POLL_MS = 30000;
|
||||||
|
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||||
|
|
||||||
|
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||||
|
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||||
|
|
||||||
|
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
|
||||||
|
export type HostState = {
|
||||||
|
stats: HostStats | null;
|
||||||
|
error: string | null;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
|
||||||
|
const [stats, setStats] = useState<HostStats | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const reload = useCallback(
|
||||||
|
() =>
|
||||||
|
api.host
|
||||||
|
.stats()
|
||||||
|
.then((s) => {
|
||||||
|
setStats(s);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
void reload();
|
||||||
|
if (!pollMs) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") void reload();
|
||||||
|
}, pollMs);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [enabled, pollMs, reload]);
|
||||||
|
return { stats, error, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||||
|
export function Meter({ value, label }: { value: number; label: string }) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="meter"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuenow={Math.round(v)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The System Health tab's own bar: h-2, green / amber / red by level. */
|
||||||
|
function HealthBar({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tip,
|
||||||
|
warn = 70,
|
||||||
|
bad = 90,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
tip?: string;
|
||||||
|
warn?: number;
|
||||||
|
bad?: number;
|
||||||
|
}) {
|
||||||
|
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
|
||||||
|
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
|
||||||
|
return (
|
||||||
|
<Tip block raw={tip}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-[10px]">
|
||||||
|
<span className="text-fg-muted">{label}</span>
|
||||||
|
<span className="text-fg-secondary font-mono">
|
||||||
|
{value == null ? "—" : `${Math.round(v)}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="meter"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuenow={Math.round(v)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
className="w-full bg-surface-3 rounded-full h-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
|
||||||
|
style={{ width: `${v}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
|
||||||
|
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex justify-between text-[10px] font-mono">
|
||||||
|
<span className="text-fg-muted">{n}</span>
|
||||||
|
<span className={tone}>{Math.round(v)}%</span>
|
||||||
|
</div>
|
||||||
|
<Meter value={v} label={label} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHead({
|
||||||
|
icon: Icon,
|
||||||
|
tone,
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
tone: string;
|
||||||
|
title: string;
|
||||||
|
right?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
|
||||||
|
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
|
||||||
|
</div>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
|
||||||
|
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||||
|
const d = Math.floor(sec / 86400);
|
||||||
|
const h = Math.floor((sec % 86400) / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
|
||||||
|
export type HeapInfo = {
|
||||||
|
heapUsed: number;
|
||||||
|
heapTotal: number;
|
||||||
|
heapLimit?: number;
|
||||||
|
external: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab's first card: the machine and the AgentHub process on it.
|
||||||
|
* `heap` comes from the tab's settings read.
|
||||||
|
*/
|
||||||
|
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
const s = host.stats;
|
||||||
|
const mem = s?.memory;
|
||||||
|
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
|
||||||
|
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
|
||||||
|
return (
|
||||||
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
|
||||||
|
<CardHead
|
||||||
|
icon={Server}
|
||||||
|
tone="text-status-success"
|
||||||
|
title={t("host.title")}
|
||||||
|
right={
|
||||||
|
s && (
|
||||||
|
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
|
||||||
|
{s.hostname}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!s ? (
|
||||||
|
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Row label={t("host.os")}>
|
||||||
|
{s.os.distro ?? s.os.platform} · {s.os.release}
|
||||||
|
</Row>
|
||||||
|
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
|
||||||
|
<Row label={t("host.cpuModel")}>
|
||||||
|
<Tip raw={s.cpu.model ?? undefined}>
|
||||||
|
<span>
|
||||||
|
{s.cpu.physicalCores
|
||||||
|
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
|
||||||
|
: ""}
|
||||||
|
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
|
||||||
|
</span>
|
||||||
|
</Tip>
|
||||||
|
</Row>
|
||||||
|
{s.os.platform !== "win32" && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||||||
|
{t("host.load")}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
{s.cpu.load.slice(0, 3).map((load, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||||||
|
>
|
||||||
|
{load.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.process && (
|
||||||
|
<Row label={t("host.agenthub")}>
|
||||||
|
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
|
||||||
|
<span>
|
||||||
|
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
|
||||||
|
</span>
|
||||||
|
</Tip>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||||
|
{s.usage && (
|
||||||
|
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
|
||||||
|
)}
|
||||||
|
<HealthBar
|
||||||
|
label={t("host.memory")}
|
||||||
|
value={mem ? pct(mem.used, mem.total) : null}
|
||||||
|
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||||
|
/>
|
||||||
|
{mem && mem.swapTotal > 0 && (
|
||||||
|
<HealthBar
|
||||||
|
label={t("host.swap")}
|
||||||
|
value={pct(mem.swapUsed, mem.swapTotal)}
|
||||||
|
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{heap && (
|
||||||
|
<HealthBar
|
||||||
|
label={t("health.heap")}
|
||||||
|
value={heapPct}
|
||||||
|
warn={60}
|
||||||
|
bad={85}
|
||||||
|
tip={t("health.heapTip", {
|
||||||
|
pct: (heapPct ?? 0).toFixed(1),
|
||||||
|
used: formatBytes(heap.heapUsed),
|
||||||
|
limit: formatBytes(heapMax),
|
||||||
|
allocated: formatBytes(heap.heapTotal),
|
||||||
|
external: formatBytes(heap.external),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tab's machine group: host + history, CPU / disks / processes, users. */
|
||||||
|
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
const { stats } = host;
|
||||||
|
const [range, setRange] = useState<HostHistoryRange>("1h");
|
||||||
|
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||||
|
// The user tile whose busiest processes are shown.
|
||||||
|
const [open, setOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(
|
||||||
|
() =>
|
||||||
|
api.host
|
||||||
|
.history(range)
|
||||||
|
.then(setHistory)
|
||||||
|
.catch(() => setHistory(null)),
|
||||||
|
[range]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void loadHistory();
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") void loadHistory();
|
||||||
|
}, HISTORY_POLL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [loadHistory]);
|
||||||
|
|
||||||
|
// Same tree as the full render below, so the card is not remounted once stats arrive.
|
||||||
|
if (!stats)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<HostCard host={host} heap={heap} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = stats.usage?.groups ?? [];
|
||||||
|
const groupName = (g: HostUsageGroup) => {
|
||||||
|
if (g.kind === "host") return t("host.hostGroup");
|
||||||
|
if (g.kind === "system") return t("host.systemGroup");
|
||||||
|
return g.displayName || g.username || "";
|
||||||
|
};
|
||||||
|
const groupByKey = new Map(groups.map((g) => [g.key, g]));
|
||||||
|
const ownerOf = (key: string) => {
|
||||||
|
const g = groupByKey.get(key);
|
||||||
|
if (g) return groupName(g);
|
||||||
|
if (key === "host") return t("host.hostGroup");
|
||||||
|
if (key === "system") return t("host.systemGroup");
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||||
|
const timeLabel = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
const histPoints = (key: "cpu" | "memory") =>
|
||||||
|
(history?.points ?? [])
|
||||||
|
.filter((p) => p[key] != null)
|
||||||
|
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
|
||||||
|
const perCore = stats.cpu.perCore ?? [];
|
||||||
|
const network = stats.network ?? [];
|
||||||
|
const temps = stats.temperatures ?? [];
|
||||||
|
const topProcs = stats.topProcesses ?? [];
|
||||||
|
const others = groups.filter((g) => g.kind !== "user");
|
||||||
|
const people = groups.filter((g) => g.kind === "user");
|
||||||
|
// History, per-thread CPU and processes are read from /proc: on Windows and
|
||||||
|
// macOS their cards would only ever be empty, so they are left out.
|
||||||
|
const linux = stats.os.platform === "linux";
|
||||||
|
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
|
||||||
|
const showProcs = !!stats.usage || others.length > 0;
|
||||||
|
|
||||||
|
// A whole disk's I/O goes on the first mounted file system that lives on it
|
||||||
|
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
|
||||||
|
// mounted gets a row of its own.
|
||||||
|
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
|
||||||
|
const diskRows = stats.disks.map((d) => {
|
||||||
|
const base = d.device.split("/").pop() ?? "";
|
||||||
|
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
|
||||||
|
const io = key ? ioLeft.get(key) : undefined;
|
||||||
|
if (key) ioLeft.delete(key);
|
||||||
|
return { disk: d, io };
|
||||||
|
});
|
||||||
|
const loneIo = [...ioLeft.values()];
|
||||||
|
|
||||||
|
const rate = (down?: number, up?: number) => (
|
||||||
|
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<ArrowDown className="w-2.5 h-2.5" />
|
||||||
|
{down == null ? "—" : perSec(down)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<ArrowUp className="w-2.5 h-2.5" />
|
||||||
|
{up == null ? "—" : perSec(up)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<HostCard host={host} heap={heap} />
|
||||||
|
|
||||||
|
{/* CPU and memory over time. */}
|
||||||
|
{linux && (
|
||||||
|
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
||||||
|
<CardHead
|
||||||
|
icon={Activity}
|
||||||
|
tone="text-accent"
|
||||||
|
title={t("host.historyTitle")}
|
||||||
|
right={
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("host.range")}
|
||||||
|
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
||||||
|
>
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRange(r)}
|
||||||
|
aria-pressed={range === r}
|
||||||
|
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||||
|
range === r
|
||||||
|
? "bg-surface-4 text-fg-secondary"
|
||||||
|
: "text-fg-muted hover:text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`host.range${r}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
||||||
|
<MiniChart
|
||||||
|
kind="line"
|
||||||
|
points={histPoints("cpu")}
|
||||||
|
max={100}
|
||||||
|
format={fmtPct}
|
||||||
|
label={t("host.historyCpu")}
|
||||||
|
emptyText={t("host.historyNone")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
||||||
|
<MiniChart
|
||||||
|
kind="line"
|
||||||
|
points={histPoints("memory")}
|
||||||
|
max={100}
|
||||||
|
format={fmtPct}
|
||||||
|
label={t("host.historyMemory")}
|
||||||
|
emptyText={t("host.historyNone")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{history?.startedAt && (
|
||||||
|
<p className="text-[10px] text-fg-muted">
|
||||||
|
{t("host.historyHelp", {
|
||||||
|
s: history.intervalMs / 1000,
|
||||||
|
at: formatDateTime(history.startedAt),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
|
||||||
|
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
||||||
|
{showCores && (
|
||||||
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
||||||
|
<CardHead
|
||||||
|
icon={Cpu}
|
||||||
|
tone="text-syntax-keyword"
|
||||||
|
title={t("host.cpu")}
|
||||||
|
right={
|
||||||
|
<span className="text-[10px] font-mono text-fg-muted">
|
||||||
|
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{perCore.length > 0 && (
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
||||||
|
{perCore.map((v, i) => (
|
||||||
|
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{temps.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="border-t border-border/40 pt-3 space-y-2"
|
||||||
|
data-testid="server-temperatures"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
||||||
|
<Thermometer className="w-3 h-3 text-status-warning" />
|
||||||
|
{t("host.temperatures")}
|
||||||
|
</div>
|
||||||
|
{temps.map((s, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
||||||
|
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||||
|
s.celsius >= 90
|
||||||
|
? "bg-status-danger/20 text-status-danger"
|
||||||
|
: s.celsius >= 75
|
||||||
|
? "bg-status-warning/20 text-status-warning"
|
||||||
|
: "bg-surface-3 text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.celsius.toFixed(0)} °C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.gpus.length > 0 && (
|
||||||
|
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||||
|
{stats.gpus.map((g, i) => (
|
||||||
|
<HealthBar
|
||||||
|
key={i}
|
||||||
|
label={g.name}
|
||||||
|
value={g.utilization}
|
||||||
|
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Disks (with their I/O) and network interfaces. */}
|
||||||
|
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
|
||||||
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
|
||||||
|
<CardHead
|
||||||
|
icon={HardDrive}
|
||||||
|
tone="text-syntax-builtin"
|
||||||
|
title={t("host.storageNetwork")}
|
||||||
|
/>
|
||||||
|
<div className="space-y-3" data-testid="server-disk-io">
|
||||||
|
{diskRows.map(({ disk: d, io }) => (
|
||||||
|
<div key={d.device} className="space-y-1">
|
||||||
|
<HealthBar
|
||||||
|
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
|
||||||
|
value={pct(d.used, d.total)}
|
||||||
|
warn={75}
|
||||||
|
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-[10px] font-mono text-fg-muted">
|
||||||
|
{formatBytes(d.free)} {t("host.free")}
|
||||||
|
</span>
|
||||||
|
{io && rate(io.read, io.write)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loneIo.map((io) => (
|
||||||
|
<div key={io.name} className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
|
||||||
|
<span className="ml-auto">{rate(io.read, io.write)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{network.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="border-t border-border/40 pt-3 space-y-2"
|
||||||
|
data-testid="server-network"
|
||||||
|
>
|
||||||
|
{network.map((n) => (
|
||||||
|
<div key={n.name} className="flex items-center gap-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||||
|
<Network className="w-3 h-3 text-fg-muted" />
|
||||||
|
<span className="font-mono">{n.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The busiest processes, and what belongs to no user. */}
|
||||||
|
{showProcs && (
|
||||||
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
||||||
|
<CardHead
|
||||||
|
icon={ListTree}
|
||||||
|
tone="text-syntax-function"
|
||||||
|
title={t("host.topProcesses")}
|
||||||
|
right={
|
||||||
|
<span className="text-[10px] font-mono text-fg-muted">
|
||||||
|
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!stats.usage ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topProcs.map((p) => (
|
||||||
|
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs">
|
||||||
|
<span className="font-mono text-fg-secondary">{p.name}</span>
|
||||||
|
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-mono text-fg-muted">
|
||||||
|
{formatBytes(p.memory)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
||||||
|
>
|
||||||
|
{fmtPct(p.cpu)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="border-t border-border/40 pt-3 space-y-1.5"
|
||||||
|
data-testid="server-usage-table"
|
||||||
|
>
|
||||||
|
{others.map((g) => (
|
||||||
|
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
||||||
|
{groupName(g)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-fg-secondary">
|
||||||
|
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
||||||
|
{t("host.processCount", { n: g.processes })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Each user's live share of the machine (server mode). */}
|
||||||
|
{people.length > 0 && (
|
||||||
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
|
||||||
|
<CardHead
|
||||||
|
icon={Users}
|
||||||
|
tone="text-syntax-string"
|
||||||
|
title={t("host.usersTitle")}
|
||||||
|
right={
|
||||||
|
<span className="text-[10px] font-mono text-fg-muted">
|
||||||
|
{t("host.usersCount", { n: people.length })}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{people.map((g) => {
|
||||||
|
const name = groupName(g);
|
||||||
|
const isOpen = open === g.key;
|
||||||
|
return (
|
||||||
|
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||||
|
disabled={!g.top.length}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||||
|
>
|
||||||
|
<UserAvatar name={name} className="h-6 w-6 text-[10px]" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-mono text-fg-muted">
|
||||||
|
{t("host.processCount", { n: g.processes })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||||
|
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||||
|
{isOpen && (
|
||||||
|
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
||||||
|
{g.top.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.pid}
|
||||||
|
className="flex justify-between gap-2 text-[10px] font-mono"
|
||||||
|
>
|
||||||
|
<span className="truncate text-fg-secondary">
|
||||||
|
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0 text-fg-muted">
|
||||||
|
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* @file HostHealth.test.tsx
|
||||||
|
* @description The machine on the Dashboard's System Health tab
|
||||||
|
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
|
||||||
|
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
|
||||||
|
* rows under it show every CPU thread, temperatures, each disk with its I/O
|
||||||
|
* rate and each network interface, the CPU / memory history with its range
|
||||||
|
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||||
|
* counts for, the processes of no user in a short list of their own, and
|
||||||
|
* each user's live CPU / RAM as a tile (their busiest processes on click);
|
||||||
|
* off Linux the cards it can never fill are left out.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import type { HostStats } from "../../../lib/api";
|
||||||
|
|
||||||
|
const GB = 1024 ** 3;
|
||||||
|
const stats: HostStats = {
|
||||||
|
at: "2026-10-15T00:00:00Z",
|
||||||
|
hostname: "box",
|
||||||
|
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
|
||||||
|
uptime: 90000,
|
||||||
|
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
|
||||||
|
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
|
||||||
|
disks: [
|
||||||
|
{
|
||||||
|
device: "/dev/sdb2",
|
||||||
|
mount: "/",
|
||||||
|
type: "ext4",
|
||||||
|
kind: "SSD",
|
||||||
|
total: 200 * GB,
|
||||||
|
used: 50 * GB,
|
||||||
|
free: 150 * GB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
device: "/dev/sda1",
|
||||||
|
mount: "/data",
|
||||||
|
type: "ext4",
|
||||||
|
kind: "HDD",
|
||||||
|
total: 500 * GB,
|
||||||
|
used: 0,
|
||||||
|
free: 500 * GB,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
homesMount: "/",
|
||||||
|
gpus: [],
|
||||||
|
usage: {
|
||||||
|
cpu: 40,
|
||||||
|
sampleMs: 1000,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: "user:2",
|
||||||
|
kind: "user",
|
||||||
|
userId: 2,
|
||||||
|
username: "mia",
|
||||||
|
cpu: 25,
|
||||||
|
memory: 2 * GB,
|
||||||
|
memoryPercent: 12.5,
|
||||||
|
processes: 3,
|
||||||
|
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
|
||||||
|
},
|
||||||
|
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
host: { stats: vi.fn(), history: vi.fn() },
|
||||||
|
me: { avatarUrl: () => null },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return { ...actual, api };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { HostSections, useHostStats } from "../HostHealth";
|
||||||
|
|
||||||
|
function Tab() {
|
||||||
|
const host = useHostStats();
|
||||||
|
return (
|
||||||
|
<HostSections
|
||||||
|
host={host}
|
||||||
|
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const full: HostStats = {
|
||||||
|
...stats,
|
||||||
|
usage: {
|
||||||
|
...stats.usage!,
|
||||||
|
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
|
||||||
|
},
|
||||||
|
bootTime: "2026-10-14T00:00:00Z",
|
||||||
|
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
|
||||||
|
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
|
||||||
|
diskIo: [{ name: "sda", read: 4096, write: 0 }],
|
||||||
|
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
|
||||||
|
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
|
||||||
|
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
api.host.stats.mockResolvedValue(full);
|
||||||
|
api.host.history.mockResolvedValue({
|
||||||
|
range: "1h",
|
||||||
|
intervalMs: 30000,
|
||||||
|
running: true,
|
||||||
|
startedAt: "2026-10-15T00:00:00Z",
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
at: "2026-10-15T00:00:00Z",
|
||||||
|
cpu: 30,
|
||||||
|
memory: 50,
|
||||||
|
memoryUsed: 0,
|
||||||
|
swapUsed: 0,
|
||||||
|
load1: 1,
|
||||||
|
users: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: "2026-10-15T00:00:30Z",
|
||||||
|
cpu: 40,
|
||||||
|
memory: 52,
|
||||||
|
memoryUsed: 0,
|
||||||
|
swapUsed: 0,
|
||||||
|
load1: 1,
|
||||||
|
users: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HostHealth", () => {
|
||||||
|
it("names the machine and the AgentHub process, with bars", async () => {
|
||||||
|
render(<Tab />);
|
||||||
|
const card = await screen.findByTestId("server-facts");
|
||||||
|
expect(await within(card).findByText("box")).toBeTruthy();
|
||||||
|
expect(card.textContent).toContain("Ubuntu 26.04");
|
||||||
|
expect(card.textContent).toContain("1.0 GB");
|
||||||
|
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||||
|
"40"
|
||||||
|
);
|
||||||
|
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
|
||||||
|
"50"
|
||||||
|
);
|
||||||
|
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
|
||||||
|
render(<Tab />);
|
||||||
|
expect(await screen.findByTestId("server-history")).toBeTruthy();
|
||||||
|
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
|
||||||
|
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
|
||||||
|
const disks = screen.getByTestId("server-disk-io").textContent;
|
||||||
|
expect(disks).toContain("/data · HDD");
|
||||||
|
expect(disks).toContain("4.0 KB/s");
|
||||||
|
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
|
||||||
|
const top = screen.getByTestId("server-top-processes");
|
||||||
|
expect(await within(top).findByText("Mia")).toBeTruthy();
|
||||||
|
const other = screen.getByTestId("server-usage-table");
|
||||||
|
expect(within(other).queryByText("Mia")).toBeNull();
|
||||||
|
expect(within(other).getByText(/^Host account/)).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
|
||||||
|
expect(api.host.history).toHaveBeenLastCalledWith("24h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows each user's live CPU and RAM, their processes on click", async () => {
|
||||||
|
render(<Tab />);
|
||||||
|
const tile = await screen.findByTestId("server-user");
|
||||||
|
expect(tile.textContent).toContain("Mia");
|
||||||
|
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||||
|
"25"
|
||||||
|
);
|
||||||
|
expect(tile.textContent).toContain("2.0 GB");
|
||||||
|
expect(within(tile).queryByText("claude")).toBeNull();
|
||||||
|
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
|
||||||
|
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||||
|
api.host.stats.mockResolvedValue({
|
||||||
|
...stats,
|
||||||
|
os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" },
|
||||||
|
cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] },
|
||||||
|
disks: [],
|
||||||
|
usage: null,
|
||||||
|
});
|
||||||
|
api.host.history.mockResolvedValue({
|
||||||
|
range: "1h",
|
||||||
|
intervalMs: 30000,
|
||||||
|
running: false,
|
||||||
|
points: [],
|
||||||
|
});
|
||||||
|
render(<Tab />);
|
||||||
|
const card = await screen.findByTestId("server-facts");
|
||||||
|
expect(await within(card).findByText(/win32/)).toBeTruthy();
|
||||||
|
expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull();
|
||||||
|
expect(card.textContent).not.toContain("Load");
|
||||||
|
expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy();
|
||||||
|
for (const id of [
|
||||||
|
"server-history",
|
||||||
|
"server-cores",
|
||||||
|
"server-top-processes",
|
||||||
|
"server-disks-table",
|
||||||
|
])
|
||||||
|
expect(screen.queryByTestId(id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||||
|
* A running clone shows `CloneProgress` and never holds the page: closing the
|
||||||
|
* dialog only hides it behind a small progress chip (click to reopen), the
|
||||||
|
* request keeps going, and its outcome arrives as a toast — a failure is kept
|
||||||
|
* for the next open so a login row is still there to fill in.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,6 +22,8 @@ import { 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";
|
||||||
import { cloneDirName } from "./AddLaneModal";
|
import { cloneDirName } from "./AddLaneModal";
|
||||||
|
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||||
|
|
||||||
@@ -26,12 +32,15 @@ export function CloneHereDialog({
|
|||||||
parent,
|
parent,
|
||||||
onClose,
|
onClose,
|
||||||
onCloned,
|
onCloned,
|
||||||
|
onReopen,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** The absolute folder the clone goes into. */
|
/** The absolute folder the clone goes into. */
|
||||||
parent: string;
|
parent: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCloned: (lane: Lane, path: string) => void;
|
onCloned: (lane: Lane, path: string) => void;
|
||||||
|
/** Show the dialog again from the background chip. */
|
||||||
|
onReopen: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -42,10 +51,23 @@ export function CloneHereDialog({
|
|||||||
const [sshRefused, setSshRefused] = useState(false);
|
const [sshRefused, setSshRefused] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
/** Where the running clone goes (the open folder may change meanwhile). */
|
||||||
|
const [target, setTarget] = useState("");
|
||||||
|
// Read by the request's own continuation and by the reset below.
|
||||||
|
const openRef = useRef(open);
|
||||||
|
openRef.current = open;
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
/** A clone failed while hidden: the next open shows that, not a blank form. */
|
||||||
|
const keepRef = useRef(false);
|
||||||
|
useDialog(open, panelRef, onClose);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open || busyRef.current) return;
|
||||||
|
if (keepRef.current) {
|
||||||
|
keepRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setUrl("");
|
setUrl("");
|
||||||
setName("");
|
setName("");
|
||||||
setLogin(null);
|
setLogin(null);
|
||||||
@@ -54,14 +76,39 @@ export function CloneHereDialog({
|
|||||||
setError(null);
|
setError(null);
|
||||||
}, [open, parent]);
|
}, [open, parent]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
const folder = name.trim() || cloneDirName(url);
|
const folder = name.trim() || cloneDirName(url);
|
||||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
if (!busy || !token) return null;
|
||||||
|
return createPortal(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="fx-clone-chip"
|
||||||
|
onClick={onReopen}
|
||||||
|
title={into}
|
||||||
|
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||||
|
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{t("clone.cloning")} {into.split("/").pop()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<CloneProgress token={token} />
|
||||||
|
</button>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const u = url.trim();
|
const u = url.trim();
|
||||||
if (!u || busy) return;
|
if (!u || busy) return;
|
||||||
|
const progressToken = newCloneToken();
|
||||||
|
busyRef.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setToken(progressToken);
|
||||||
|
setTarget(into);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSshRefused(false);
|
setSshRefused(false);
|
||||||
try {
|
try {
|
||||||
@@ -69,6 +116,7 @@ export function CloneHereDialog({
|
|||||||
url: u,
|
url: u,
|
||||||
parent,
|
parent,
|
||||||
name: name.trim() || undefined,
|
name: name.trim() || undefined,
|
||||||
|
progressToken,
|
||||||
...(login?.username.trim() && login.token
|
...(login?.username.trim() && login.token
|
||||||
? {
|
? {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -79,12 +127,16 @@ export function CloneHereDialog({
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
busyRef.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
setToken(null);
|
||||||
onCloned(result.lane, result.path);
|
onCloned(result.lane, result.path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||||
const host = e.auth?.host || u;
|
const host = e.auth?.host || u;
|
||||||
|
let message: string;
|
||||||
if (e.code === "ECREDENTIALS") {
|
if (e.code === "ECREDENTIALS") {
|
||||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||||
setLogin((l) => ({
|
setLogin((l) => ({
|
||||||
host,
|
host,
|
||||||
username: l?.username ?? "",
|
username: l?.username ?? "",
|
||||||
@@ -93,18 +145,25 @@ export function CloneHereDialog({
|
|||||||
}));
|
}));
|
||||||
} else if (e.code === "ESSHAUTH") {
|
} else if (e.code === "ESSHAUTH") {
|
||||||
setSshRefused(true);
|
setSshRefused(true);
|
||||||
setError(t("clone.sshRefused", { host }));
|
message = t("clone.sshRefused", { host });
|
||||||
} else {
|
} else {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
message = err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
setError(message);
|
||||||
|
busyRef.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setToken(null);
|
||||||
|
if (!openRef.current) {
|
||||||
|
keepRef.current = true;
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||||
onClick={busy ? undefined : onClose}
|
onClick={onClose}
|
||||||
// A portal still bubbles through React: keep keys away from the File
|
// A portal still bubbles through React: keep keys away from the File
|
||||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
@@ -119,7 +178,10 @@ export function CloneHereDialog({
|
|||||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
<h2
|
||||||
|
id={titleId}
|
||||||
|
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
<FolderGit2 className="h-4 w-4" />
|
<FolderGit2 className="h-4 w-4" />
|
||||||
{t("fx.cloneRepo")}
|
{t("fx.cloneRepo")}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -162,7 +224,10 @@ export function CloneHereDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{login && (
|
{login && (
|
||||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
<div
|
||||||
|
data-testid="fx-clone-login"
|
||||||
|
className="space-y-2 rounded-md border border-border p-2"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={t("clone.username")}
|
aria-label={t("clone.username")}
|
||||||
placeholder={t("clone.username")}
|
placeholder={t("clone.username")}
|
||||||
@@ -193,6 +258,7 @@ export function CloneHereDialog({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{busy && token && <CloneProgress token={token} />}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-status-danger">
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
{error}{" "}
|
{error}{" "}
|
||||||
@@ -204,8 +270,8 @@ export function CloneHereDialog({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||||
{t("fx.cancel")}
|
{busy ? t("clone.background") : t("fx.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
* another user's. An admin's Trash opens on one folder per user (with its
|
* another user's. An admin's Trash opens on one folder per user (with its
|
||||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||||
* without waiting the 7 days.
|
* without waiting the 7 days.
|
||||||
|
* The sidebar nests each workspace under its owner's home (a chevron drops
|
||||||
|
* them down, remembered per browser); one with no owner home stays in a flat
|
||||||
|
* Workspaces list.
|
||||||
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||||
* (server/lib/file-explorer.js).
|
* (server/lib/file-explorer.js).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -82,6 +85,7 @@ import {
|
|||||||
type FsTrashItem,
|
type FsTrashItem,
|
||||||
type FsHome,
|
type FsHome,
|
||||||
type FsPlace,
|
type FsPlace,
|
||||||
|
type GitSource,
|
||||||
isHomePlace,
|
isHomePlace,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
@@ -92,6 +96,8 @@ import { formatBytes } from "../../lib/format";
|
|||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { CloneHereDialog } from "./CloneHereDialog";
|
import { CloneHereDialog } from "./CloneHereDialog";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { useAuth } from "../AuthGate";
|
||||||
|
|
||||||
/** One upload batch at most (the server caps one file the same). */
|
/** One upload batch at most (the server caps one file the same). */
|
||||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
@@ -136,6 +142,11 @@ export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
|||||||
const MIME = "application/x-agenthub-fx";
|
const MIME = "application/x-agenthub-fx";
|
||||||
const VIEW_KEY = "agenthub.files.view";
|
const VIEW_KEY = "agenthub.files.view";
|
||||||
const HIDDEN_KEY = "agenthub.files.hidden";
|
const HIDDEN_KEY = "agenthub.files.hidden";
|
||||||
|
/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */
|
||||||
|
const HOMES_OPEN_KEY = "agenthub.files.homesOpen";
|
||||||
|
/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the
|
||||||
|
* viewer may not open that home (an analyst's view of other users). */
|
||||||
|
type UserRow = FsHome & { noHome?: boolean };
|
||||||
|
|
||||||
type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||||
@@ -273,7 +284,7 @@ export function FileBrowser({
|
|||||||
/** A home to open on (`me`, or another user's for an admin). */
|
/** A home to open on (`me`, or another user's for an admin). */
|
||||||
initialHome?: string | null;
|
initialHome?: string | null;
|
||||||
session?: string | null;
|
session?: string | null;
|
||||||
source?: number | null;
|
source?: GitSource | null;
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
@@ -318,6 +329,16 @@ export function FileBrowser({
|
|||||||
bytes: number;
|
bytes: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||||
|
const { state: auth } = useAuth();
|
||||||
|
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||||
|
const [openHomes, setOpenHomes] = useState<Set<string>>(() => {
|
||||||
|
try {
|
||||||
|
const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]'));
|
||||||
|
return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||||||
|
} catch {
|
||||||
|
return new Set(["me"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
const [clip, setClip] = useState<Clip | null>(clipboard);
|
const [clip, setClip] = useState<Clip | null>(clipboard);
|
||||||
const [dropping, setDropping] = useState(false);
|
const [dropping, setDropping] = useState(false);
|
||||||
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
|
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
|
||||||
@@ -891,6 +912,52 @@ export function FileBrowser({
|
|||||||
onCancel={() => setRenaming(null)}
|
onCancel={() => setRenaming(null)}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
// Each workspace drops down under its owner: the caller's own under My
|
||||||
|
// home, another user's under theirs (`owner_name` = that home's id). An
|
||||||
|
// owner whose home this viewer may not open (an analyst's view of other
|
||||||
|
// users) still gets a row, one that only groups. A workspace with no owner
|
||||||
|
// stays in the flat Workspaces list. Which rows are open is per browser.
|
||||||
|
const { lanesOfHome, userRows } = useMemo(() => {
|
||||||
|
const ids = new Set(homes.map((h) => h.id));
|
||||||
|
const map = new Map<string, Lane[]>();
|
||||||
|
const extra = new Map<string, UserRow>();
|
||||||
|
for (const l of lanes) {
|
||||||
|
const home =
|
||||||
|
myId != null && l.owner_id === myId && ids.has("me")
|
||||||
|
? "me"
|
||||||
|
: l.owner_name && l.owner_name !== "me"
|
||||||
|
? l.owner_name
|
||||||
|
: null;
|
||||||
|
if (!home) continue;
|
||||||
|
map.set(home, [...(map.get(home) ?? []), l]);
|
||||||
|
if (!ids.has(home) && !extra.has(home)) {
|
||||||
|
extra.set(home, {
|
||||||
|
id: home,
|
||||||
|
label: l.owner_display_name || home,
|
||||||
|
readOnly: true,
|
||||||
|
noHome: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rows: UserRow[] = [
|
||||||
|
...homes.filter((h) => h.id !== "me"),
|
||||||
|
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||||
|
];
|
||||||
|
return { lanesOfHome: map, userRows: rows };
|
||||||
|
}, [homes, lanes, myId]);
|
||||||
|
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
||||||
|
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
|
||||||
|
// A home holding the open workspace shows it, whatever was stored.
|
||||||
|
const homeIsOpen = (id: string, own: Lane[]) =>
|
||||||
|
openHomes.has(id) ||
|
||||||
|
(view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target)));
|
||||||
|
const toggleHome = (id: string, own: Lane[]) => {
|
||||||
|
const next = new Set(openHomes);
|
||||||
|
if (homeIsOpen(id, own)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
setOpenHomes(next);
|
||||||
|
writePref(HOMES_OPEN_KEY, JSON.stringify([...next]));
|
||||||
|
};
|
||||||
const placeClass = (active: boolean) =>
|
const placeClass = (active: boolean) =>
|
||||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
|
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
|
||||||
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
|
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
|
||||||
@@ -908,6 +975,7 @@ export function FileBrowser({
|
|||||||
open={cloning}
|
open={cloning}
|
||||||
parent={cloneParent}
|
parent={cloneParent}
|
||||||
onClose={() => setCloning(false)}
|
onClose={() => setCloning(false)}
|
||||||
|
onReopen={() => setCloning(true)}
|
||||||
onCloned={(lane, at) => {
|
onCloned={(lane, at) => {
|
||||||
setCloning(false);
|
setCloning(false);
|
||||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||||
@@ -919,64 +987,104 @@ export function FileBrowser({
|
|||||||
{/* ── Places ── */}
|
{/* ── Places ── */}
|
||||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||||
{homes.some((h) => h.id === "me") && (
|
{(() => {
|
||||||
<div className="pt-3">
|
const laneRow = (l: Lane, nested: boolean) => (
|
||||||
<button
|
<button
|
||||||
|
key={l.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go("", { home: "me" })}
|
title={l.cwd}
|
||||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
onClick={() => go("", l.id)}
|
||||||
className={placeClass(
|
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||||
view === "files" && isHomePlace(target) && target.home === "me"
|
className={`${placeClass(
|
||||||
)}
|
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||||
|
)}${nested ? " pl-7" : ""}`}
|
||||||
>
|
>
|
||||||
<House className="h-4 w-4 shrink-0" />
|
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||||
<span className="truncate">{t("fx.myHome")}</span>
|
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
);
|
||||||
)}
|
// A home row with a chevron: its user's workspaces drop down under it.
|
||||||
{homes.some((h) => h.id !== "me") && (
|
const homeRow = (h: UserRow) => {
|
||||||
<>
|
const own = lanesOfHome.get(h.id) ?? [];
|
||||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
const open = own.length > 0 && homeIsOpen(h.id, own);
|
||||||
{t("fx.userHomes")}
|
const mine = h.id === "me";
|
||||||
</p>
|
// No home to open: the name drops the list down too.
|
||||||
{homes
|
const browse = !h.noHome;
|
||||||
.filter((h) => h.id !== "me")
|
return (
|
||||||
.map((h) => (
|
<div key={h.id} data-testid={`fx-home-${h.id}`}>
|
||||||
<button
|
<div className="flex items-center">
|
||||||
key={h.id}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={t("fx.readOnlyHome")}
|
aria-label={t("fx.toggleWorkspaces", {
|
||||||
onClick={() => go("", { home: h.id })}
|
name: mine ? t("fx.myHome") : h.label,
|
||||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
})}
|
||||||
className={placeClass(
|
aria-expanded={open}
|
||||||
view === "files" && isHomePlace(target) && target.home === h.id
|
disabled={!own.length}
|
||||||
)}
|
onClick={() => toggleHome(h.id, own)}
|
||||||
>
|
className="flex h-6 w-5 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-2 disabled:opacity-0"
|
||||||
<UserRound className="h-4 w-4 shrink-0" />
|
>
|
||||||
<span className="truncate">{h.label}</span>
|
{open ? (
|
||||||
<Eye className="ml-auto h-3 w-3 shrink-0 opacity-60" />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
</button>
|
) : (
|
||||||
))}
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
</>
|
)}
|
||||||
)}
|
</button>
|
||||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
<button
|
||||||
{t("fx.places")}
|
type="button"
|
||||||
</p>
|
title={mine || !browse ? undefined : t("fx.readOnlyHome")}
|
||||||
{lanes.map((l) => (
|
aria-expanded={browse ? undefined : open}
|
||||||
<button
|
onClick={() => (browse ? go("", { home: h.id }) : toggleHome(h.id, own))}
|
||||||
key={l.id}
|
onContextMenu={browse ? (e) => placeCtx.open(e, { home: h.id }) : undefined}
|
||||||
type="button"
|
className={placeClass(
|
||||||
title={l.cwd}
|
browse && view === "files" && isHomePlace(target) && target.home === h.id
|
||||||
onClick={() => go("", l.id)}
|
)}
|
||||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
>
|
||||||
className={placeClass(
|
{mine ? (
|
||||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
<House className="h-4 w-4 shrink-0" />
|
||||||
)}
|
) : (
|
||||||
>
|
<UserRound className="h-4 w-4 shrink-0" />
|
||||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
)}
|
||||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
<span className="truncate">{mine ? t("fx.myHome") : h.label}</span>
|
||||||
</button>
|
{own.length > 0 && (
|
||||||
))}
|
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||||
|
{own.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!mine && browse && (
|
||||||
|
<Eye
|
||||||
|
className={`${own.length ? "" : "ml-auto "}h-3 w-3 shrink-0 opacity-60`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && own.map((l) => laneRow(l, true))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const me = homes.find((h) => h.id === "me");
|
||||||
|
const others = userRows;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{me && <div className="pt-3">{homeRow(me)}</div>}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{others.some((h) => !h.noHome) ? t("fx.userHomes") : t("fx.users")}
|
||||||
|
</p>
|
||||||
|
{others.map(homeRow)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{looseLanes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t("fx.places")}
|
||||||
|
</p>
|
||||||
|
{looseLanes.map((l) => laneRow(l, false))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||||
@@ -1775,7 +1883,7 @@ export function FileExplorer({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session?: string | null;
|
session?: string | null;
|
||||||
source?: number | null;
|
source?: GitSource | null;
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -1987,7 +2095,7 @@ function InlineName({
|
|||||||
onDoubleClick={(e) => e.stopPropagation()}
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.key === "Enter") onDone();
|
if (isSubmitEnter(e)) onDone();
|
||||||
if (e.key === "Escape") onCancel();
|
if (e.key === "Escape") onCancel();
|
||||||
}}
|
}}
|
||||||
onBlur={onDone}
|
onBlur={onDone}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
UnfoldVertical,
|
UnfoldVertical,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
import { pasteToLane } from "../../lib/panePaste";
|
import { pasteToLane } from "../../lib/panePaste";
|
||||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||||
@@ -827,7 +827,7 @@ export function LaneChangesPanel({
|
|||||||
session?: string;
|
session?: string;
|
||||||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||||
* wins over `session`, and is keyed on like it. */
|
* wins over `session`, and is keyed on like it. */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||||
onNewLane?: (branch?: string) => void;
|
onNewLane?: (branch?: string) => void;
|
||||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||||
@@ -1063,6 +1063,9 @@ export function LaneChangesPanel({
|
|||||||
const [historyLoading, setHistoryLoading] = useState(false);
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "history") return;
|
if (mode !== "history") return;
|
||||||
|
// A reply for the previous ref/file/page lands after the next request
|
||||||
|
// started: drop it, or it overwrites the list the user is looking at.
|
||||||
|
let live = true;
|
||||||
setHistoryLoading(true);
|
setHistoryLoading(true);
|
||||||
void api.lanes
|
void api.lanes
|
||||||
.gitLog(target, {
|
.gitLog(target, {
|
||||||
@@ -1070,16 +1073,23 @@ export function LaneChangesPanel({
|
|||||||
...(historyRef ? { ref: historyRef } : {}),
|
...(historyRef ? { ref: historyRef } : {}),
|
||||||
...(historyFile ? { file: historyFile } : {}),
|
...(historyFile ? { file: historyFile } : {}),
|
||||||
})
|
})
|
||||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
.then(forLane(laneId, (r) => live && setCommits(r.commits)))
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
|
||||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
.finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
|
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "history") return;
|
if (mode !== "history") return;
|
||||||
|
let live = true;
|
||||||
void api.lanes
|
void api.lanes
|
||||||
.gitBranches(target)
|
.gitBranches(target)
|
||||||
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
|
.then(forLane(laneId, (r) => live && setHistoryBranches(r.branches)))
|
||||||
.catch(() => setHistoryBranches([]));
|
.catch(() => live && setHistoryBranches([]));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
}, [mode, laneId, reread]);
|
}, [mode, laneId, reread]);
|
||||||
|
|
||||||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||||||
@@ -1173,24 +1183,33 @@ export function LaneChangesPanel({
|
|||||||
// own fetch — and only while the viewer is actually showing it.
|
// own fetch — and only while the viewer is actually showing it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
||||||
|
// Opening file B before file A's read came back must not show (and let the
|
||||||
|
// editor save) A's text under B's header.
|
||||||
|
let live = true;
|
||||||
void api.lanes
|
void api.lanes
|
||||||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||||
.then(
|
.then(
|
||||||
forLane(laneId, (r) =>
|
forLane(
|
||||||
setText(
|
laneId,
|
||||||
r.available
|
(r) =>
|
||||||
? {
|
live &&
|
||||||
path: r.path,
|
setText(
|
||||||
lines: r.lines,
|
r.available
|
||||||
truncated: r.truncated,
|
? {
|
||||||
binary: r.binary,
|
path: r.path,
|
||||||
sha: r.sha,
|
lines: r.lines,
|
||||||
}
|
truncated: r.truncated,
|
||||||
: null
|
binary: r.binary,
|
||||||
)
|
sha: r.sha,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
||||||
// The editor's original side, read beside the working file. A file that is
|
// The editor's original side, read beside the working file. A file that is
|
||||||
// not there (new since) diffs against nothing: every line is added.
|
// not there (new since) diffs against nothing: every line is added.
|
||||||
@@ -1198,10 +1217,17 @@ export function LaneChangesPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
||||||
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
||||||
|
let live = true;
|
||||||
void api.lanes.gitFile(target, openFile, rev).then(
|
void api.lanes.gitFile(target, openFile, rev).then(
|
||||||
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
|
forLane(
|
||||||
forLane(laneId, () => setBaseText(""))
|
laneId,
|
||||||
|
(r) => live && setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")
|
||||||
|
),
|
||||||
|
forLane(laneId, () => live && setBaseText(""))
|
||||||
);
|
);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
applyChoices,
|
applyChoices,
|
||||||
parseConflicts,
|
parseConflicts,
|
||||||
@@ -47,7 +47,7 @@ export function LaneConflictView({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
path: string;
|
path: string;
|
||||||
/** The file left the conflict (resolved, or a whole side taken). */
|
/** The file left the conflict (resolved, or a whole side taken). */
|
||||||
onResolved: () => void;
|
onResolved: () => void;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
|
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
@@ -98,7 +98,7 @@ export function LaneGitBar({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
git: LaneGitFacts;
|
git: LaneGitFacts;
|
||||||
/** Rendered at the start of the action row: the header's branch line, so
|
/** Rendered at the start of the action row: the header's branch line, so
|
||||||
* the facts and the buttons that act on them read as one row. */
|
* the facts and the buttons that act on them read as one row. */
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GitBranch } from "lucide-react";
|
import { GitBranch } from "lucide-react";
|
||||||
import { LaneGitBar } from "./LaneGitBar";
|
import { LaneGitBar } from "./LaneGitBar";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import { keepIfSame } from "../../lib/keepIfSame";
|
import { keepIfSame } from "../../lib/keepIfSame";
|
||||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ export function useLaneGitFacts(
|
|||||||
laneId: number,
|
laneId: number,
|
||||||
/** Read that session's own worktree instead (`?session=`). */
|
/** Read that session's own worktree instead (`?session=`). */
|
||||||
session?: string,
|
session?: string,
|
||||||
/** Read that extra folder's repo instead (`?source=`). */
|
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
|
||||||
source?: number
|
source?: GitSource
|
||||||
): [LaneGitFacts | null, () => void] {
|
): [LaneGitFacts | null, () => void] {
|
||||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||||
@@ -329,7 +329,7 @@ export function LaneGitRailCounts({
|
|||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session?: string;
|
session?: string;
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
/** Opens the Changes panel from the file list's "… more files". */
|
/** Opens the Changes panel from the file list's "… more files". */
|
||||||
onShowAll?: () => void;
|
onShowAll?: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -349,7 +349,7 @@ export function LaneGitHeader({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
git: LaneGitFacts | null;
|
git: LaneGitFacts | null;
|
||||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||||
* `resumeById` (omp) can resume a session by id — any other tool's session
|
* `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session
|
||||||
* offers a new session from the workspace home
|
* offers a new session from the workspace home
|
||||||
* instead. The header carries the transcript page's Resume/Fork
|
* instead. The header carries the transcript page's Resume/Fork
|
||||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||||
* existing call: Rename (the row's inline field), New workspace here (the
|
* existing call: Rename (the row's inline field), New workspace here (the
|
||||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||||
* managed lane. The server's guards are the real ones; the menu only hides
|
* managed lane — plus Move to group (the sidebar's own groups,
|
||||||
|
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
|
||||||
|
* server's guards are the real ones; the menu only hides
|
||||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||||
* Escape / outside-click close) is `ContextMenu`.
|
* Escape / outside-click close) is `ContextMenu`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -11,7 +13,9 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||||
|
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
|
||||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||||
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
|
|||||||
y,
|
y,
|
||||||
onClose,
|
onClose,
|
||||||
onAction,
|
onAction,
|
||||||
|
groups = [],
|
||||||
|
groupId = null,
|
||||||
|
onGroup,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Items the tree itself carries out (rename field, modals). */
|
/** Items the tree itself carries out (rename field, modals). */
|
||||||
onAction: (action: WorkspaceMenuAction) => void;
|
onAction: (action: WorkspaceMenuAction) => void;
|
||||||
|
/** The sidebar's groups and the one this workspace is in. */
|
||||||
|
groups?: WorkspaceGroup[];
|
||||||
|
groupId?: string | null;
|
||||||
|
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
|
||||||
|
onGroup?: (target: string | null | "new") => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
|
|||||||
>
|
>
|
||||||
{t("menu.copyPath")}
|
{t("menu.copyPath")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{onGroup && (
|
||||||
|
<>
|
||||||
|
<MenuSeparator />
|
||||||
|
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
|
{t("menu.moveToGroup")}
|
||||||
|
</div>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<MenuItem
|
||||||
|
key={g.id}
|
||||||
|
testId={`workspace-move-group-${g.id}`}
|
||||||
|
disabled={g.id === groupId}
|
||||||
|
onSelect={() => onGroup(g.id)}
|
||||||
|
>
|
||||||
|
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
|
||||||
|
<span className="truncate">{g.name}</span>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
{t("menu.newGroupWith")}
|
||||||
|
</MenuItem>
|
||||||
|
{groupId && (
|
||||||
|
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
{t("menu.removeFromGroup")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{lane.kind === "managed" && (
|
{lane.kind === "managed" && (
|
||||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
|
|||||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||||
tool("gemini", "Gemini"),
|
tool("gemini", "Gemini"),
|
||||||
tool("aider", "Aider", { installed: false, path: null }),
|
tool("aider", "Aider", { installed: false, path: null }),
|
||||||
tool("grok", "Grok", { enabled: false }),
|
tool("goose", "Goose", { enabled: false }),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||||
* one saying why), opens each user's Trash from its folder (asking before a
|
* one saying why), opens each user's Trash from its folder (asking before a
|
||||||
* purge there, sending the item's store) and
|
* purge there, sending the item's store) and
|
||||||
* shows a Trash that could not be read as an error, never as empty.
|
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||||
|
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||||
|
* when the viewer may not open that home).
|
||||||
* @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";
|
||||||
@@ -28,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
lanes: {
|
lanes: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
clone: vi.fn(),
|
clone: vi.fn(),
|
||||||
|
cloneProgress: vi.fn(),
|
||||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
@@ -470,6 +473,59 @@ describe("FileExplorer", () => {
|
|||||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
|
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
|
||||||
expect(api.files.remove).not.toHaveBeenCalled();
|
expect(api.files.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops a user's workspaces down under their home; an unowned one stays in the list", async () => {
|
||||||
|
localStorage.removeItem("agenthub.files.homesOpen");
|
||||||
|
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||||
|
lanes: [
|
||||||
|
{ id: 1, title: "one", cwd: "/w/one" },
|
||||||
|
{ id: 2, title: "two", cwd: "/w/two", owner_id: 7, owner_name: "bob" },
|
||||||
|
],
|
||||||
|
counts: {},
|
||||||
|
} as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const toggle = await screen.findByRole("button", { name: "Show or hide Bob’s workspaces" });
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||||
|
expect(within(places).queryByRole("button", { name: "two" })).toBeNull();
|
||||||
|
expect(within(places).getByRole("button", { name: "one" })).toBeInTheDocument();
|
||||||
|
await user.click(toggle);
|
||||||
|
await user.click(within(places).getByRole("button", { name: "two" }));
|
||||||
|
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||||
|
expect(localStorage.getItem("agenthub.files.homesOpen")).toContain("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups an owner whose home the viewer cannot open (an analyst) under a row that only folds", async () => {
|
||||||
|
localStorage.removeItem("agenthub.files.homesOpen");
|
||||||
|
vi.mocked(api.files.homes).mockResolvedValue({
|
||||||
|
homes: [{ id: "me", label: "An", readOnly: false }],
|
||||||
|
});
|
||||||
|
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||||
|
lanes: [
|
||||||
|
{ id: 1, title: "one", cwd: "/w/one" },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "three",
|
||||||
|
cwd: "/w/three",
|
||||||
|
owner_id: 9,
|
||||||
|
owner_name: "cara",
|
||||||
|
owner_display_name: "Cara",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
counts: {},
|
||||||
|
} as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||||
|
expect(within(places).getByText("Users")).toBeInTheDocument();
|
||||||
|
expect(within(places).queryByRole("button", { name: "three" })).toBeNull();
|
||||||
|
await user.click(within(places).getByRole("button", { name: /^Cara/ }));
|
||||||
|
expect(api.files.list).not.toHaveBeenCalledWith({ home: "cara" }, "");
|
||||||
|
expect(within(places).getByRole("button", { name: "three" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FileExplorer — New → Clone repository", () => {
|
describe("FileExplorer — New → Clone repository", () => {
|
||||||
@@ -498,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
|
|||||||
url: "git@host:o/repo.git",
|
url: "git@host:o/repo.git",
|
||||||
parent: "/w/one/src",
|
parent: "/w/one/src",
|
||||||
name: undefined,
|
name: undefined,
|
||||||
|
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(toast.success).toHaveBeenCalled();
|
expect(toast.success).toHaveBeenCalled();
|
||||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows progress and lets the page go on while the clone runs", async () => {
|
||||||
|
let finish: (v: unknown) => void = () => {};
|
||||||
|
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
|
||||||
|
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||||
|
state: "running",
|
||||||
|
position: null,
|
||||||
|
running: 1,
|
||||||
|
waiting: 0,
|
||||||
|
limit: 2,
|
||||||
|
phase: "receiving",
|
||||||
|
label: null,
|
||||||
|
percent: 45,
|
||||||
|
detail: "450/1000",
|
||||||
|
} as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
|
||||||
|
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||||
|
const chip = await screen.findByTestId("fx-clone-chip");
|
||||||
|
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
|
||||||
|
await user.click(chip);
|
||||||
|
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
finish({
|
||||||
|
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||||
|
created: true,
|
||||||
|
path: "/w/one/repo",
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
|
||||||
|
let fail: (e: unknown) => void = () => {};
|
||||||
|
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
|
||||||
|
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
fail(
|
||||||
|
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||||
|
const again = await openClone(user);
|
||||||
|
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
|
||||||
|
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
|
||||||
|
"https://git.example/o/r.git"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||||
vi.mocked(api.lanes.clone)
|
vi.mocked(api.lanes.clone)
|
||||||
.mockRejectedValueOnce(
|
.mockRejectedValueOnce(
|
||||||
|
|||||||
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||||
name: "Tick every file in server/lib",
|
name: "Tick every file in server/lib",
|
||||||
});
|
});
|
||||||
expect(box).toBeChecked();
|
// The default ticks land in an effect after the list renders.
|
||||||
|
await waitFor(() => expect(box).toBeChecked());
|
||||||
await user.click(box);
|
await user.click(box);
|
||||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||||
await user.click(folder);
|
await user.click(folder);
|
||||||
|
|||||||
@@ -765,7 +765,7 @@ export function Divider({
|
|||||||
onRatio,
|
onRatio,
|
||||||
label,
|
label,
|
||||||
}: {
|
}: {
|
||||||
gridRef: React.RefObject<HTMLDivElement>;
|
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||||
dir: SplitDir;
|
dir: SplitDir;
|
||||||
at: number;
|
at: number;
|
||||||
box: Rect;
|
box: Rect;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||||
* as an `@path` chip — so is a local file a browser on the dashboard's
|
* as an `@path` chip — so is a local file a browser on the dashboard's
|
||||||
* own machine drops or pastes with its `file://` path, never uploaded —
|
* own machine drops or pastes with its `file://` path, or pastes from a
|
||||||
|
* file manager's Copy its OS clipboard holds, never uploaded —
|
||||||
* a `bare` one takes a drop anywhere on its box;
|
* a `bare` one takes a drop anywhere on its box;
|
||||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||||
* the textarea (`lib/promptTokens.ts`);
|
* the textarea (`lib/promptTokens.ts`);
|
||||||
@@ -70,6 +71,7 @@ import {
|
|||||||
ATTACH_MIME,
|
ATTACH_MIME,
|
||||||
addAttachments,
|
addAttachments,
|
||||||
claudeMention,
|
claudeMention,
|
||||||
|
copiedFilePaths,
|
||||||
droppedAttachments,
|
droppedAttachments,
|
||||||
droppedSkill,
|
droppedSkill,
|
||||||
droppedText,
|
droppedText,
|
||||||
@@ -77,6 +79,8 @@ import {
|
|||||||
localAttachment,
|
localAttachment,
|
||||||
localCandidates,
|
localCandidates,
|
||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
|
fileManagerPaths,
|
||||||
|
isClipboardBitmap,
|
||||||
SKILL_MIME,
|
SKILL_MIME,
|
||||||
rememberUploads,
|
rememberUploads,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
@@ -452,23 +456,36 @@ export function PromptEditor({
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
/** A file manager's copy pasted with no file beside it, only `file://`
|
||||||
|
* text: attached by those paths, as the terminal pastes them. */
|
||||||
|
const pastedLocalFiles = (dt: DataTransfer | null | undefined) => {
|
||||||
|
const paths = isLocalBrowser() ? copiedFilePaths(dt) : null;
|
||||||
|
if (!paths) return false;
|
||||||
|
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
/** Dropped or pasted files: by their own paths when the browser is on the
|
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||||
* dashboard's machine and the server finds the same files there (a file
|
* dashboard's machine and the server finds the same files there (a file
|
||||||
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
* manager hands over `file://` paths beside the bytes) or its file manager
|
||||||
|
* holds them (a paste: its clipboard; a drop: its selection — no upload,
|
||||||
|
* no size limit), else uploaded. */
|
||||||
const attachDropped = (
|
const attachDropped = (
|
||||||
dt: DataTransfer | null | undefined,
|
dt: DataTransfer | null | undefined,
|
||||||
list: FileList | null | undefined
|
list: FileList | File[] | null | undefined,
|
||||||
|
from: "paste" | "drop"
|
||||||
) => {
|
) => {
|
||||||
const files = Array.from(list ?? []);
|
const files = Array.from(list ?? []);
|
||||||
if (files.length === 0) return false;
|
if (files.length === 0) return false;
|
||||||
const paths = localCandidates(dt, files);
|
if (!isLocalBrowser()) return attachImages(files);
|
||||||
if (!paths) return attachImages(files);
|
const candidates = localCandidates(dt, files);
|
||||||
setUpload("uploading");
|
setUpload("uploading");
|
||||||
void sameFilesHere(paths, files).then((same) => {
|
void (async () => {
|
||||||
if (!same) return void attachImages(files);
|
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
|
||||||
|
paths ??= await fileManagerPaths(files, from);
|
||||||
|
if (!paths) return void attachImages(files);
|
||||||
setUpload(null);
|
setUpload(null);
|
||||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||||
});
|
})();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||||
@@ -509,7 +526,7 @@ export function PromptEditor({
|
|||||||
} else if (skill) {
|
} else if (skill) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) e.preventDefault();
|
||||||
else {
|
else {
|
||||||
// Only text (a file manager's or an editor's `file://` list, a
|
// Only text (a file manager's or an editor's `file://` list, a
|
||||||
// selection, a link): what the terminal beneath would paste.
|
// selection, a link): what the terminal beneath would paste.
|
||||||
@@ -674,16 +691,21 @@ export function PromptEditor({
|
|||||||
onBlur={() => setTrigger(null)}
|
onBlur={() => setTrigger(null)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
const pasted = Array.from(e.clipboardData?.files ?? []);
|
||||||
else {
|
const text = e.clipboardData?.getData?.("text/plain") ?? "";
|
||||||
|
// Cells from a spreadsheet come with a picture of them: the text
|
||||||
|
// is what was copied (isClipboardBitmap).
|
||||||
|
const textWithPicture =
|
||||||
|
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
|
||||||
|
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
|
||||||
|
e.preventDefault();
|
||||||
|
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
|
||||||
|
else if (text.length > PASTE_AS_FILE_CHARS) {
|
||||||
// A very long paste goes in as a text file, like Claude Desktop:
|
// A very long paste goes in as a text file, like Claude Desktop:
|
||||||
// the prompt then carries its path, never the text (#68: a CLI's
|
// the prompt then carries its path, never the text (#68: a CLI's
|
||||||
// argument and a tmux message both have a size limit).
|
// argument and a tmux message both have a size limit).
|
||||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
e.preventDefault();
|
||||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||||
e.preventDefault();
|
|
||||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -863,7 +885,7 @@ export function CwdAutocomplete({
|
|||||||
} else if (e.key === "ArrowUp") {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActive((a) => Math.max(0, a - 1));
|
setActive((a) => Math.max(0, a - 1));
|
||||||
} else if (e.key === "Enter") {
|
} else if (isSubmitEnter(e)) {
|
||||||
if (open && flat[active]) {
|
if (open && flat[active]) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
choose(flat[active]);
|
choose(flat[active]);
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||||
* reads them when the prompt is sent — an image as its bare path, which claude
|
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||||
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
|
* shows as `[Image #n]` like a native drop — except a PASTED image in a
|
||||||
|
* `claude` pane of a local-mode dashboard opened on its own machine, which is
|
||||||
|
* sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the
|
||||||
|
* desktop clipboard itself, uploading nothing — and a file manager's files
|
||||||
|
* pasted or dropped on that machine go in by the paths it holds them under
|
||||||
|
* (its clipboard / its selection: `POST /api/run/clipboard-files`,
|
||||||
|
* `selected-files`), with no upload and no size limit
|
||||||
|
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
|
||||||
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||||
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||||
@@ -41,7 +48,9 @@
|
|||||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||||
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
||||||
* here takes the lead back (`resize` with `drive: true`).
|
* here takes the lead back (`resize` with `drive: true`). Cells are drawn by
|
||||||
|
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
|
||||||
|
* the word-move keys xterm 5 sent (`altArrowSeq`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +65,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
import type { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||||
import type { SearchAddon } from "@xterm/addon-search";
|
import type { SearchAddon } from "@xterm/addon-search";
|
||||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||||
@@ -65,24 +74,24 @@ import { useTheme } from "../../hooks/useTheme";
|
|||||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { writeClipboard } from "../../lib/clipboard";
|
import { writeClipboard } from "../../lib/clipboard";
|
||||||
import {
|
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
|
||||||
MENU_CURSOR_ROW,
|
|
||||||
notePaneFocus,
|
|
||||||
notePaneOutput,
|
|
||||||
registerPane,
|
|
||||||
} from "../../lib/panePaste";
|
|
||||||
import {
|
import {
|
||||||
claudePastes,
|
claudePastes,
|
||||||
|
copiedFilePaths,
|
||||||
|
isClipboardBitmap,
|
||||||
droppedFilePaths,
|
droppedFilePaths,
|
||||||
|
fileManagerPaths,
|
||||||
isLocalBrowser,
|
isLocalBrowser,
|
||||||
localCandidates,
|
localCandidates,
|
||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
|
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||||
|
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";
|
||||||
|
|
||||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
|
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
||||||
|
|
||||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||||
@@ -116,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
|
|||||||
* Workspace chunk. The result is cached, so every later pane (and a test that
|
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||||
* awaits `loadXterm()` first) sets up synchronously. */
|
* awaits `loadXterm()` first) sets up synchronously. */
|
||||||
const importXterm = async () => {
|
const importXterm = async () => {
|
||||||
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
|
const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
|
||||||
import("@xterm/xterm"),
|
import("@xterm/xterm"),
|
||||||
import("@xterm/addon-fit"),
|
import("@xterm/addon-fit"),
|
||||||
import("@xterm/addon-canvas"),
|
import("@xterm/addon-webgl"),
|
||||||
import("@xterm/addon-clipboard"),
|
import("@xterm/addon-clipboard"),
|
||||||
import("@xterm/addon-search"),
|
import("@xterm/addon-search"),
|
||||||
import("@xterm/addon-web-links"),
|
import("@xterm/addon-web-links"),
|
||||||
@@ -127,7 +136,7 @@ const importXterm = async () => {
|
|||||||
return {
|
return {
|
||||||
Terminal: xterm.Terminal,
|
Terminal: xterm.Terminal,
|
||||||
FitAddon: fit.FitAddon,
|
FitAddon: fit.FitAddon,
|
||||||
CanvasAddon: canvas.CanvasAddon,
|
WebglAddon: webgl.WebglAddon,
|
||||||
SearchAddon: search.SearchAddon,
|
SearchAddon: search.SearchAddon,
|
||||||
WebLinksAddon: links.WebLinksAddon,
|
WebLinksAddon: links.WebLinksAddon,
|
||||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||||
@@ -151,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
|
|||||||
return xtermLoading;
|
return xtermLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
|
||||||
|
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
|
||||||
|
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
|
||||||
|
* readline's backward-word / forward-word and whose up/down stay Alt's. */
|
||||||
|
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
|
||||||
|
switch (key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
return mac ? "\x1bb" : "\x1b[1;5D";
|
||||||
|
case "ArrowRight":
|
||||||
|
return mac ? "\x1bf" : "\x1b[1;5C";
|
||||||
|
case "ArrowUp":
|
||||||
|
return mac ? null : "\x1b[1;5A";
|
||||||
|
case "ArrowDown":
|
||||||
|
return mac ? null : "\x1b[1;5B";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
||||||
const RETRY_MIN_MS = 1500;
|
const RETRY_MIN_MS = 1500;
|
||||||
const RETRY_MAX_MS = 30_000;
|
const RETRY_MAX_MS = 30_000;
|
||||||
@@ -160,15 +188,6 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
|
|||||||
const hasText = (e: DragEvent) =>
|
const hasText = (e: DragEvent) =>
|
||||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||||
|
|
||||||
/** The `file://` paths a paste carries as text — a file manager's copy — or
|
|
||||||
* null: its uri-list, else its plain text when every line is one. */
|
|
||||||
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
|
|
||||||
const text = dt?.getData("text/plain") ?? "";
|
|
||||||
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
|
|
||||||
if (!/^file:\/\//.test(text.trim())) return null;
|
|
||||||
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
runId: string;
|
runId: string;
|
||||||
wsBaseUrl: string;
|
wsBaseUrl: string;
|
||||||
@@ -252,6 +271,12 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
|
|
||||||
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||||
const ctrlVRef = useRef(false);
|
const ctrlVRef = useRef(false);
|
||||||
|
const { local: localMode } = usePermissions();
|
||||||
|
// Ask early whether `^V` can reach this machine's clipboard, so the first
|
||||||
|
// pasted screenshot already takes the right way.
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks();
|
||||||
|
}, [tool, localMode]);
|
||||||
|
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
if (hasText(e)) {
|
if (hasText(e)) {
|
||||||
@@ -269,7 +294,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
void attachFiles(files, localCandidates(e.dataTransfer, files));
|
void attachFiles(files, localCandidates(e.dataTransfer, files), "drop");
|
||||||
};
|
};
|
||||||
|
|
||||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||||
@@ -290,9 +315,27 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||||
|
// Cells from a spreadsheet, a page's selection: text WITH a picture of
|
||||||
|
// it. A terminal pastes the text there, so does this.
|
||||||
|
if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
// On the dashboard's own machine the browser's clipboard IS the pane's:
|
||||||
|
// `claude` reads a pasted image itself on ^V, as in a native terminal —
|
||||||
|
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
|
||||||
|
const nativeImage =
|
||||||
|
tool === "claude" &&
|
||||||
|
localMode &&
|
||||||
|
isLocalBrowser() &&
|
||||||
|
readNativePaste() &&
|
||||||
|
nativePasteWorks() &&
|
||||||
|
files.length > 0 &&
|
||||||
|
files.every(isClipboardBitmap);
|
||||||
|
if (nativeImage) {
|
||||||
|
termRef.current?.input(nativePasteKey(), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste");
|
||||||
else termRef.current?.input("\x16", true);
|
else termRef.current?.input("\x16", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -312,11 +355,15 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||||
* browser on the dashboard's machine is handed beside the bytes) once the
|
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||||
* server finds the same files there — nothing uploaded —, else the paths
|
* server finds the same files there — nothing uploaded —, else the paths
|
||||||
* the uploads were saved at. A refused file does not cost the others. */
|
* that machine's file manager holds them under (a paste: its clipboard; a
|
||||||
const attachFiles = async (files: File[], local: string[] | null = null) => {
|
* drop: its selection, what the drag carries — no upload, no size limit,
|
||||||
|
* as a native terminal), else the paths the uploads were saved at. A
|
||||||
|
* refused file does not cost the others. */
|
||||||
|
const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
setDrop("uploading");
|
setDrop("uploading");
|
||||||
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
||||||
|
paths ??= await fileManagerPaths(files, from);
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
if (!paths) {
|
if (!paths) {
|
||||||
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
||||||
@@ -334,7 +381,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||||
if (!errors.length) setDrop(null);
|
if (!errors.length) setDrop(null);
|
||||||
else if (!tooBig) flash("failed");
|
else if (!tooBig) flash("failed");
|
||||||
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
|
// A terminal has no + menu: on this machine, a paste goes by path.
|
||||||
|
else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -342,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
const attach = ({
|
const attach = ({
|
||||||
Terminal,
|
Terminal,
|
||||||
FitAddon,
|
FitAddon,
|
||||||
CanvasAddon,
|
WebglAddon,
|
||||||
SearchAddon,
|
SearchAddon,
|
||||||
WebLinksAddon,
|
WebLinksAddon,
|
||||||
ClipboardWithProvider,
|
ClipboardWithProvider,
|
||||||
@@ -352,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||||
// substitutes those specific glyphs from a different face with a
|
// substitutes those specific glyphs from a different face with a
|
||||||
// mismatched advance width, corrupting the cell it's drawn into even
|
// mismatched advance width, corrupting the cell it's drawn into even
|
||||||
// under the canvas renderer (which only stops a glyph from shoving its
|
// under the WebGL renderer (which only stops a glyph from shoving its
|
||||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||||
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||||
@@ -398,17 +446,30 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// face whose advance width differs, so glyphs shove their neighbours along
|
// face whose advance width differs, so glyphs shove their neighbours along
|
||||||
// and the row's tail spills onto the row below. The buffer is correct
|
// and the row's tail spills onto the row below. The buffer is correct
|
||||||
// throughout (copying the line yields clean text); only the painting drifts,
|
// throughout (copying the line yields clean text); only the painting drifts,
|
||||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
|
||||||
// context keeps the DOM renderer rather than losing the terminal.
|
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
|
||||||
let canvas: CanvasAddon | null = null;
|
// keeps the DOM renderer rather than losing the terminal, and so does a
|
||||||
|
// pane whose context the browser takes back (GPU reset, or past the
|
||||||
|
// browser's cap of live contexts with many panes open) — disposing the
|
||||||
|
// addon hands the drawing back to the DOM renderer.
|
||||||
|
let gpu: WebglAddon | null = null;
|
||||||
try {
|
try {
|
||||||
canvas = new CanvasAddon();
|
const addon = new WebglAddon();
|
||||||
term.loadAddon(canvas);
|
term.loadAddon(addon);
|
||||||
|
gpu = addon;
|
||||||
|
addon.onContextLoss(() => {
|
||||||
|
addon.dispose();
|
||||||
|
if (gpu === addon) gpu = null;
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
canvas = null;
|
gpu = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fit.fit();
|
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||||
|
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||||
|
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||||
|
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||||
|
if (hasBox()) fit.fit();
|
||||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||||
// page here, and having to click it first meant the first thing typed went
|
// page here, and having to click it first meant the first thing typed went
|
||||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||||
@@ -495,6 +556,17 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
term.input("\x1f", true);
|
term.input("\x1f", true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
|
||||||
|
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
|
||||||
|
// ESC b / ESC f for left/right on macOS — the same keys tmux,
|
||||||
|
// bash and claude's prompt have always been handed from this pane.
|
||||||
|
const altArrow =
|
||||||
|
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
|
||||||
|
if (altArrow) {
|
||||||
|
ev.preventDefault();
|
||||||
|
term.input(altArrow, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
@@ -537,7 +609,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
*/
|
*/
|
||||||
const sendSizeNow = (drive = false) => {
|
const sendSizeNow = (drive = false) => {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
if (term.cols < 2 || term.rows < 2) return;
|
if (!hasBox() || term.cols < 2 || term.rows < 2) return;
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
||||||
};
|
};
|
||||||
// This browser is the one in use now: back to its own size, and the
|
// This browser is the one in use now: back to its own size, and the
|
||||||
@@ -574,7 +646,10 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// scrollback as torn fragments along the left margin, visible as soon as
|
// scrollback as torn fragments along the left margin, visible as soon as
|
||||||
// you scroll back (issue #33). Happens on every attach: page load,
|
// you scroll back (issue #33). Happens on every attach: page load,
|
||||||
// lane switch, and each reconnect.
|
// lane switch, and each reconnect.
|
||||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
// A hidden terminal sends none: the server attaches it at the window's
|
||||||
|
// own size, so a pane on a background tab is never resized by it.
|
||||||
|
const size =
|
||||||
|
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||||
ws = sock;
|
ws = sock;
|
||||||
wsRef.current = sock;
|
wsRef.current = sock;
|
||||||
@@ -669,6 +744,9 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||||
if (follow) return scaleToFit(); // the driver's grid, not ours
|
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||||
fit.fit(); // a real change fires onResize, which reports it
|
fit.fit(); // a real change fires onResize, which reports it
|
||||||
|
// A tab shown for the first time may fit to the very size xterm already
|
||||||
|
// had (no onResize), yet tmux has never heard it: report it anyway.
|
||||||
|
sendSize();
|
||||||
};
|
};
|
||||||
const resizeObserver = new ResizeObserver(refit);
|
const resizeObserver = new ResizeObserver(refit);
|
||||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||||
@@ -707,7 +785,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
resizeDisposable.dispose();
|
resizeDisposable.dispose();
|
||||||
dataDisposable.dispose();
|
dataDisposable.dispose();
|
||||||
ws?.close();
|
ws?.close();
|
||||||
canvas?.dispose();
|
gpu?.dispose();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -952,7 +1030,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
<div
|
<div
|
||||||
data-testid="terminal-drop"
|
data-testid="terminal-drop"
|
||||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||||
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
|
drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
|
||||||
? "border-red-400 text-red-300"
|
? "border-red-400 text-red-300"
|
||||||
: "border-sky-400 text-sky-200"
|
: "border-sky-400 text-sky-200"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
files: r({ items: [] }),
|
files: r({ items: [] }),
|
||||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||||
localPaths: r({ sizes: [1] }),
|
localPaths: r({ sizes: [1] }),
|
||||||
|
clipboardFiles: r({ files: [] }),
|
||||||
|
selectedFiles: r({ files: [] }),
|
||||||
},
|
},
|
||||||
ccConfig: {
|
ccConfig: {
|
||||||
commands: r({
|
commands: r({
|
||||||
@@ -377,6 +379,35 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chrome on Windows pastes a file manager's copy as bytes only: the path
|
||||||
|
// comes off the OS clipboard, so a file over the upload limit attaches too.
|
||||||
|
it("attaches a pasted file manager copy by the path the OS clipboard holds", async () => {
|
||||||
|
vi.mocked(api.run.clipboardFiles).mockResolvedValueOnce({
|
||||||
|
files: [{ path: "C:\\Users\\u\\big.iso", size: 3_000_000_000, dir: false }],
|
||||||
|
});
|
||||||
|
vi.mocked(api.run.dropFile).mockClear();
|
||||||
|
const onImagesChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<PromptEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash={false}
|
||||||
|
fileCwd="/home/u"
|
||||||
|
images={[]}
|
||||||
|
onImagesChange={onImagesChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const iso = new File(["x"], "big.iso");
|
||||||
|
Object.defineProperty(iso, "size", { value: 3_000_000_000 });
|
||||||
|
fireEvent.paste(box(), { clipboardData: { files: [iso], getData: () => "" } });
|
||||||
|
await vi.waitFor(() => expect(onImagesChange).toHaveBeenCalled());
|
||||||
|
const update = onImagesChange.mock.calls[0]![0] as (p: unknown[]) => { path: string }[];
|
||||||
|
expect(update([]).map((a) => a.path)).toEqual(["@C:/Users/u/big.iso"]);
|
||||||
|
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
||||||
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
||||||
const onImagesChange = vi.fn();
|
const onImagesChange = vi.fn();
|
||||||
|
|||||||
@@ -6,13 +6,24 @@
|
|||||||
* included) as outgoing binary frames, and
|
* included) as outgoing binary frames, and
|
||||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||||
* after `exit` or a policy close,
|
* after `exit` or a policy close,
|
||||||
* and recolours live on a dark-theme change without re-attaching.
|
* recolours live on a dark-theme change without re-attaching, draws through
|
||||||
|
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
|
||||||
|
* Alt+arrow word moves.
|
||||||
* @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, beforeAll, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
import { TerminalView, loadXterm } from "../TerminalView";
|
render,
|
||||||
|
cleanup,
|
||||||
|
act,
|
||||||
|
createEvent,
|
||||||
|
fireEvent,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||||
|
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||||
|
|
||||||
const writeMock = vi.fn();
|
const writeMock = vi.fn();
|
||||||
const onDataHandlers: Array<(d: string) => void> = [];
|
const onDataHandlers: Array<(d: string) => void> = [];
|
||||||
@@ -27,11 +38,18 @@ const inputMock = vi.fn();
|
|||||||
let textarea: HTMLTextAreaElement;
|
let textarea: HTMLTextAreaElement;
|
||||||
const dropFileMock = vi.fn();
|
const dropFileMock = vi.fn();
|
||||||
const localPathsMock = vi.fn();
|
const localPathsMock = vi.fn();
|
||||||
|
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
|
||||||
|
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
|
||||||
|
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||||
|
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
run: {
|
run: {
|
||||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||||
localPaths: (paths: string[]) => localPathsMock(paths),
|
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||||
|
nativePaste: () => nativePasteMock(),
|
||||||
|
clipboardFiles: () => clipboardFilesMock(),
|
||||||
|
selectedFiles: () => selectedFilesMock(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -93,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
|
|||||||
vi.mock("@xterm/addon-fit", () => ({
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@xterm/addon-canvas", () => ({
|
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||||
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
|
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
|
||||||
|
last: null,
|
||||||
|
};
|
||||||
|
vi.mock("@xterm/addon-webgl", () => ({
|
||||||
|
WebglAddon: vi.fn().mockImplementation(() => {
|
||||||
|
let onLoss = () => {};
|
||||||
|
const addon = {
|
||||||
|
__webgl: true,
|
||||||
|
dispose: vi.fn(),
|
||||||
|
onContextLoss: (fn: () => void) => {
|
||||||
|
onLoss = fn;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
|
||||||
|
return addon;
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
@@ -120,6 +154,17 @@ class MockWebSocket {
|
|||||||
// @ts-expect-error test override
|
// @ts-expect-error test override
|
||||||
global.WebSocket = MockWebSocket;
|
global.WebSocket = MockWebSocket;
|
||||||
|
|
||||||
|
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
|
||||||
|
* tab); returns the restore. */
|
||||||
|
function withBox(width = 800, height = 600) {
|
||||||
|
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
|
||||||
|
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
|
||||||
|
return () => {
|
||||||
|
w.mockRestore();
|
||||||
|
h.mockRestore();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("TerminalView", () => {
|
describe("TerminalView", () => {
|
||||||
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||||
beforeAll(() => loadXterm());
|
beforeAll(() => loadXterm());
|
||||||
@@ -151,11 +196,51 @@ describe("TerminalView", () => {
|
|||||||
// it: tmux resizes the window to the newest client, so attaching at the
|
// it: tmux resizes the window to the newest client, so attaching at the
|
||||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||||
// leaves that width in the scrollback (issue #33).
|
// leaves that width in the scrollback (issue #33).
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
const restore = withBox();
|
||||||
expect(MockWebSocket.instances).toHaveLength(1);
|
try {
|
||||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
);
|
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||||
|
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a terminal on a hidden tab sends no size until it is shown", () => {
|
||||||
|
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
|
||||||
|
// that size resized the real tmux pane under every other client.
|
||||||
|
let observed: (() => void) | null = null;
|
||||||
|
const RO = globalThis.ResizeObserver;
|
||||||
|
// @ts-expect-error test override
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
constructor(cb: () => void) {
|
||||||
|
observed = cb;
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let restore = () => {};
|
||||||
|
try {
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||||
|
ws.onopen?.();
|
||||||
|
expect(ws.sent).toEqual([]);
|
||||||
|
// The tab is shown: the box gets a size and its size reaches tmux.
|
||||||
|
restore = withBox();
|
||||||
|
observed!();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(ws.sent).toEqual([
|
||||||
|
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
globalThis.ResizeObserver = RO;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||||
@@ -381,34 +466,75 @@ describe("TerminalView", () => {
|
|||||||
expect(onExit).toHaveBeenCalledTimes(1);
|
expect(onExit).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
it("renders through the WebGL addon, not xterm's DOM renderer", () => {
|
||||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||||
// fallback face with a different advance, so glyphs shove their neighbours
|
// fallback face with a different advance, so glyphs shove their neighbours
|
||||||
// and the row's tail spills onto the row below — buffer clean, painting
|
// and the row's tail spills onto the row below — buffer clean, painting
|
||||||
// wrong. The canvas 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).
|
||||||
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 { __canvas?: boolean }).__canvas)).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", () => {
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const addon = gpu.last!;
|
||||||
|
expect(addon.dispose).not.toHaveBeenCalled();
|
||||||
|
addon.lose();
|
||||||
|
expect(addon.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||||
|
});
|
||||||
|
|
||||||
|
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||||
|
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||||
|
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||||
|
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
|
||||||
|
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
|
||||||
|
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
|
||||||
|
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
|
||||||
|
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
|
||||||
|
expect(altArrowSeq("ArrowUp", true)).toBeNull();
|
||||||
|
expect(altArrowSeq("a", false)).toBeNull();
|
||||||
|
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
|
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
|
||||||
|
expect(handler(ev)).toBe(false);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
|
||||||
|
// Another modifier with it is xterm's own sequence.
|
||||||
|
expect(
|
||||||
|
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||||
// next row's left margin, and tmux never repaints a row it believes
|
// next row's left margin, and tmux never repaints a row it believes
|
||||||
// unchanged — the stray characters glued to the left edge after scrolling.
|
// unchanged — the stray characters glued to the left edge after scrolling.
|
||||||
it("reports its size when the socket opens", () => {
|
it("reports its size when the socket opens", () => {
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
const restore = withBox();
|
||||||
const ws = MockWebSocket.instances[0]!;
|
try {
|
||||||
ws.onopen?.();
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(ws.sent).toContain(
|
const ws = MockWebSocket.instances[0]!;
|
||||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
ws.onopen?.();
|
||||||
);
|
expect(ws.sent).toContain(
|
||||||
|
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a size change xterm made on its own, with no container resize", async () => {
|
it("reports a size change xterm made on its own, with no container resize", async () => {
|
||||||
// Webfont load and page zoom both move cols without resizing the element
|
// Webfont load and page zoom both move cols without resizing the element
|
||||||
// ResizeObserver watches, which is why onResize is the hook.
|
// ResizeObserver watches, which is why onResize is the hook.
|
||||||
|
const restore = withBox();
|
||||||
|
onTestFinished(restore);
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
ws.onopen?.();
|
ws.onopen?.();
|
||||||
@@ -590,6 +716,168 @@ describe("TerminalView", () => {
|
|||||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
inputMock.mockClear();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
expect(pasteMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
|
||||||
|
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
|
||||||
|
// paste nothing, so the screenshot is uploaded like a remote browser's.
|
||||||
|
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
nativePasteMock.mockResolvedValueOnce({ native: false });
|
||||||
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
|
||||||
|
await Promise.resolve();
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
|
||||||
|
localStorage.setItem("agenthub.terminal.nativePaste", "off");
|
||||||
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||||
|
inputMock.mockClear();
|
||||||
|
try {
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem("agenthub.terminal.nativePaste");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
|
||||||
|
// the pane gets the file's own path off the OS clipboard — no upload, so
|
||||||
|
// no size limit (a 3 GB video too), as a native terminal pastes it.
|
||||||
|
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
inputMock.mockClear();
|
||||||
|
clipboardFilesMock.mockResolvedValueOnce({
|
||||||
|
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
|
||||||
|
});
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
|
||||||
|
Object.defineProperty(video, "size", { value: 3_000_000_000 });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [video], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chrome hands a file dragged from Explorer no path; the drag carries the
|
||||||
|
// items selected there, so the pane gets their own paths — as a native
|
||||||
|
// terminal types them — and nothing is uploaded, whatever the size.
|
||||||
|
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
selectedFilesMock.mockResolvedValueOnce({
|
||||||
|
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
|
||||||
|
});
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
const video = new File([new Uint8Array([1])], "big.mkv");
|
||||||
|
Object.defineProperty(video, "size", { value: 5_000_000_000 });
|
||||||
|
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||||
|
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads a dropped file the file manager does not hold selected", async () => {
|
||||||
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
|
||||||
|
selectedFilesMock.mockResolvedValueOnce({
|
||||||
|
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
|
||||||
|
});
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
|
||||||
|
const notes = new File(["x"], "notes.txt");
|
||||||
|
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||||
|
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A copied image FILE is not a screenshot: claude's own paste finds no
|
||||||
|
// picture on a clipboard holding a file, so it goes by path too.
|
||||||
|
it("pastes a copied image file by path instead of claude's image paste", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
inputMock.mockClear();
|
||||||
|
clipboardFilesMock.mockResolvedValueOnce({
|
||||||
|
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
|
||||||
|
});
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [logo], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cells from a spreadsheet come with a picture of them; a terminal pastes
|
||||||
|
// the text, so the browser's paste is left to xterm.
|
||||||
|
it("leaves text that comes with a picture of it to xterm's paste", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
|
||||||
|
});
|
||||||
|
fireEvent(screen.getByTestId("terminal-view"), ev);
|
||||||
|
expect(ev.defaultPrevented).toBe(false);
|
||||||
|
expect(inputMock).not.toHaveBeenCalled();
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
||||||
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
||||||
// pasted like any browser paste: sending ^V for it pasted nothing.
|
// pasted like any browser paste: sending ^V for it pasted nothing.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* account's own information (`GET /api/me/account` — username, role, member
|
* account's own information (`GET /api/me/account` — username, role, member
|
||||||
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
|
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
|
||||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
|
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
|
||||||
* an https link the server downloads, `POST /api/me/avatar/url`; the sidebar
|
* an https link the browser loads itself, `POST /api/me/avatar/url`; the sidebar
|
||||||
* shows it at once), its display name
|
* shows it at once), its display name
|
||||||
* and email (`PATCH /api/me/account`), a password change that asks for the
|
* and email (`PATCH /api/me/account`), a password change that asks for the
|
||||||
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
|
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
|
||||||
@@ -31,6 +31,7 @@ import { formatDateTime } from "../../lib/format";
|
|||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { useAuth } from "../AuthGate";
|
import { useAuth } from "../AuthGate";
|
||||||
import { ROLE_BADGE } from "../users/UserEditDialog";
|
import { ROLE_BADGE } from "../users/UserEditDialog";
|
||||||
import { UserAvatar } from "../users/UserAvatar";
|
import { UserAvatar } from "../users/UserAvatar";
|
||||||
@@ -47,6 +48,24 @@ const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unkno
|
|||||||
: err.message;
|
: err.message;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Whether `url` loads as an image embedded in this page — the way every
|
||||||
|
* avatar is shown: no referrer, the cookies a cross-site image gets. */
|
||||||
|
export function loadsAsImage(url: string, timeoutMs = 10_000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
const done = (ok: boolean) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
img.onload = img.onerror = null;
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
const timer = window.setTimeout(() => done(false), timeoutMs);
|
||||||
|
img.referrerPolicy = "no-referrer";
|
||||||
|
img.onload = () => done(img.naturalWidth > 0);
|
||||||
|
img.onerror = () => done(false);
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountSettings() {
|
export function AccountSettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [account, setAccount] = useState<ServerAccount | null>(null);
|
const [account, setAccount] = useState<ServerAccount | null>(null);
|
||||||
@@ -68,19 +87,23 @@ export function AccountSettings() {
|
|||||||
);
|
);
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
api.me
|
return api.me
|
||||||
.account()
|
.account()
|
||||||
.then(apply)
|
.then(apply)
|
||||||
.catch((e: Error) => setLoadError(e.message));
|
.catch((e: Error) => setLoadError(e.message));
|
||||||
}, [apply]);
|
}, [apply]);
|
||||||
useEffect(load, [load]);
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
const [retrying, retry] = useSpin(load);
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
|
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
|
||||||
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
|
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
|
||||||
<button type="button" className="btn-ghost text-xs" onClick={load}>
|
<button type="button" className="btn-ghost text-xs" onClick={retry} disabled={retrying}>
|
||||||
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
|
<RefreshCw className={`h-3.5 w-3.5${retrying ? " animate-spin" : ""}`} />{" "}
|
||||||
|
{t("common:retry")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -133,6 +156,14 @@ function ProfileCard({
|
|||||||
const applyLink = async () => {
|
const applyLink = async () => {
|
||||||
const url = link?.trim();
|
const url = link?.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
// The avatar routes only redirect to the link, so it shows only if THIS
|
||||||
|
// browser can load it as an embedded image: a link behind a login (a
|
||||||
|
// Mattermost /api/v4/users/<id>/image) gets no cookie from another site
|
||||||
|
// and fails — say so now instead of saving a picture nobody sees.
|
||||||
|
setBusy(true);
|
||||||
|
const ok = await loadsAsImage(url);
|
||||||
|
setBusy(false);
|
||||||
|
if (!ok) return void toast.error(t("account.avatar.linkUnreachable"));
|
||||||
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
|
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
|
||||||
};
|
};
|
||||||
const pick = (e: ChangeEvent<HTMLInputElement>) => {
|
const pick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { FileUp } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, type AgentInstructionsView } from "../../lib/api";
|
import { api, type AgentInstructionsView } from "../../lib/api";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { SettingsSection } from "./SettingsRow";
|
import { SettingsSection } from "./SettingsRow";
|
||||||
@@ -41,13 +42,16 @@ export function AgentInstructionsSettings() {
|
|||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
api.me
|
return api.me
|
||||||
.instructions()
|
.instructions()
|
||||||
.then(apply)
|
.then(apply)
|
||||||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(load, []);
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
const [retrying, retry] = useSpin(load);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -123,7 +127,12 @@ export function AgentInstructionsSettings() {
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{t("agentInstructions.loadFailed", { message: loadError })}
|
{t("agentInstructions.loadFailed", { message: loadError })}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost shrink-0 text-xs"
|
||||||
|
onClick={retry}
|
||||||
|
disabled={retrying}
|
||||||
|
>
|
||||||
{t("common:retry")}
|
{t("common:retry")}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* Default badge or Set default, and Update whenever the catalog names an
|
* Default badge or Set default, and Update whenever the catalog names an
|
||||||
* updater — no version lookup, the updater itself knows whether it has work.
|
* updater — no version lookup, the updater itself knows whether it has work.
|
||||||
* The ones available to install fold behind a disclosure, with Install where
|
* The ones available to install fold behind a disclosure, with Install where
|
||||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
* the catalog knows how (an admin's; Update all is anyone's — auth.js
|
||||||
|
* MEMBER_WRITE_EXCEPTIONS). Install/Update/Update all confirm the exact command
|
||||||
* first, run as ONE server job at a time and show its log under the row
|
* first, run as ONE server job at a time and show its log under the row
|
||||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||||
* A right-click on an installed row opens its Update / Set default /
|
* A right-click on an installed row opens its Update / Set default /
|
||||||
@@ -29,6 +30,7 @@ import { AiToolIcon } from "../AiToolIcon";
|
|||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
|
||||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||||
|
|
||||||
@@ -230,7 +232,8 @@ export function AiToolsSettings({
|
|||||||
{t("aiTools.installed", { count: installed.length })}
|
{t("aiTools.installed", { count: installed.length })}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isAdmin && updateAllTools.length > 0 && (
|
{/* Every signed-in user may run Update all (auth.js MEMBER_WRITE_EXCEPTIONS). */}
|
||||||
|
{updateAllTools.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirm({ kind: "update-all" })}
|
onClick={() => setConfirm({ kind: "update-all" })}
|
||||||
@@ -374,9 +377,7 @@ export function AiToolsSettings({
|
|||||||
{ctx.menu.item.path && (
|
{ctx.menu.item.path && (
|
||||||
<>
|
<>
|
||||||
{isAdmin && <MenuSeparator />}
|
{isAdmin && <MenuSeparator />}
|
||||||
<MenuItem
|
<MenuItem onSelect={pick((tool) => void copyText(tool.path ?? ""))}>
|
||||||
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
|
||||||
>
|
|
||||||
{t("rowMenu.copyPath")}
|
{t("rowMenu.copyPath")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
* `#terminal-font`, with a preview line), and — local mode only — whether a
|
||||||
|
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
|
||||||
|
* `#native-paste`, this browser only); **Sessions** (`#sessions-view`) —
|
||||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||||
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
||||||
@@ -39,6 +41,8 @@ import {
|
|||||||
} from "../../lib/terminalFont";
|
} from "../../lib/terminalFont";
|
||||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||||||
|
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import {
|
import {
|
||||||
TERMINAL_FONT_SIZES,
|
TERMINAL_FONT_SIZES,
|
||||||
UI_FONT_SIZES,
|
UI_FONT_SIZES,
|
||||||
@@ -100,6 +104,8 @@ export function AppearanceSettings() {
|
|||||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||||
|
const [nativePaste, setNativePaste] = useState(readNativePaste);
|
||||||
|
const { local } = usePermissions();
|
||||||
const pickTermFont = (font: TerminalFont) => {
|
const pickTermFont = (font: TerminalFont) => {
|
||||||
writeTerminalFont(font);
|
writeTerminalFont(font);
|
||||||
setTermFont(font);
|
setTermFont(font);
|
||||||
@@ -209,6 +215,18 @@ export function AppearanceSettings() {
|
|||||||
>
|
>
|
||||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||||
</p>
|
</p>
|
||||||
|
{local && (
|
||||||
|
<SettingsSwitchRow
|
||||||
|
id="native-paste"
|
||||||
|
label={t("nativePaste.label")}
|
||||||
|
description={t("nativePaste.description")}
|
||||||
|
checked={nativePaste}
|
||||||
|
onChange={(on) => {
|
||||||
|
writeNativePaste(on);
|
||||||
|
setNativePaste(on);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @file ChangesPanelSettings — Settings → Git → Changes panel: whether a
|
||||||
|
* workspace folder that holds several git repositories shows each of them as
|
||||||
|
* its own section in the Changes panel (off by default: only the folder's own
|
||||||
|
* git). Writes the account preference in `lib/innerReposPref.ts`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useInnerReposMode, writeInnerReposMode } from "../../lib/innerReposPref";
|
||||||
|
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function ChangesPanelSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const mode = useInnerReposMode();
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("changesPanel.title")} description={t("changesPanel.description")}>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("changesPanel.innerRepos")}
|
||||||
|
description={t("changesPanel.innerReposDesc")}
|
||||||
|
checked={mode === "all"}
|
||||||
|
onChange={(on) => writeInnerReposMode(on ? "all" : "own")}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||||
* kind of host, the extra keys, the other hosts) — and, for
|
* kind of host, then the other hosts; a named key is a card of its own under
|
||||||
|
* its type's default one, with the same steps, and "Add another key" makes
|
||||||
|
* 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. The private key and
|
||||||
@@ -24,17 +26,7 @@
|
|||||||
*/
|
*/
|
||||||
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 {
|
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||||
Cloud,
|
|
||||||
Copy,
|
|
||||||
GitBranch,
|
|
||||||
KeyRound,
|
|
||||||
KeySquare,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { api, type GitIdentity } from "../../lib/api";
|
import { api, type GitIdentity } from "../../lib/api";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
@@ -87,26 +79,20 @@ function Step({ n, children }: { n: number; children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Code({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||||
|
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One kind of git host and the key it takes: no key → a Generate button;
|
* One SSH key and the steps of the host it is for: no key → a Generate
|
||||||
* a key → step 1 (copy its public half) followed by the host's own steps.
|
* button; a key → step 1 (copy its public half) followed by the host's own
|
||||||
|
* steps. The default key of a type and every named one share it.
|
||||||
*/
|
*/
|
||||||
function KeyCard({
|
function KeyCard({
|
||||||
title,
|
title,
|
||||||
sub,
|
sub,
|
||||||
type,
|
label,
|
||||||
k,
|
k,
|
||||||
id,
|
writable,
|
||||||
busy,
|
busy,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -114,12 +100,13 @@ function KeyCard({
|
|||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
type: "ed25519" | "rsa";
|
/** What names the public key field ("RSA", "ED25519" or the key's name). */
|
||||||
|
label: string;
|
||||||
k: Key | undefined;
|
k: Key | undefined;
|
||||||
id: GitIdentity;
|
writable: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
onGenerate: () => Promise<void>;
|
||||||
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
onRemove: () => Promise<void>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -130,23 +117,19 @@ function KeyCard({
|
|||||||
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||||
<div className="text-[10px] text-fg-muted">{sub}</div>
|
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||||
</div>
|
</div>
|
||||||
{id.writable && (
|
{writable && (
|
||||||
<button
|
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void onGenerate(type)}
|
|
||||||
>
|
|
||||||
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{k && id.writable && (
|
{k && writable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${BTN} hover:text-status-danger`}
|
className={`${BTN} hover:text-status-danger`}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void onRemove(type)}
|
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
|
||||||
|
onClick={() => void onRemove()}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
{t("gitIdentity.remove")}
|
{t("gitIdentity.remove")}
|
||||||
@@ -165,9 +148,9 @@ function KeyCard({
|
|||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
readOnly
|
||||||
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
|
||||||
value={k.publicKey}
|
value={k.publicKey}
|
||||||
rows={type === "rsa" ? 3 : 2}
|
rows={k.type === "rsa" ? 3 : 2}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||||
/>
|
/>
|
||||||
@@ -182,6 +165,104 @@ function KeyCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
|
||||||
|
* default key's login is the region's plain host; a named key's is saved
|
||||||
|
* under an account name (its own name unless another is typed), tied to it.
|
||||||
|
*/
|
||||||
|
function AwsLoginForm({
|
||||||
|
busy,
|
||||||
|
named,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
/** The named key's name; undefined for the default key. */
|
||||||
|
named?: string;
|
||||||
|
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.awsKeyId")}
|
||||||
|
placeholder="APKA…"
|
||||||
|
autoComplete="off"
|
||||||
|
value={v.keyId}
|
||||||
|
onChange={(e) => setV({ ...v, keyId: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.awsRegion")}
|
||||||
|
value={v.region}
|
||||||
|
onChange={(e) => setV({ ...v, region: e.target.value })}
|
||||||
|
className="select !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
{AWS_REGIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.alias")}
|
||||||
|
title={t("gitIdentity.aliasHint")}
|
||||||
|
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
|
||||||
|
autoComplete="off"
|
||||||
|
value={v.alias}
|
||||||
|
onChange={(e) => setV({ ...v, alias: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
|
||||||
|
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
|
||||||
|
* offers only this key — the default key would log in as the other account.
|
||||||
|
*/
|
||||||
|
function GitHostForm({
|
||||||
|
busy,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
onSave: (host: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [host, setHost] = useState("github.com");
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-1 flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSave(host.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.otherHost")}
|
||||||
|
placeholder="github.com"
|
||||||
|
autoComplete="off"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -229,19 +310,42 @@ export function GitIdentitySettings() {
|
|||||||
const keyOf = (type: "ed25519" | "rsa") =>
|
const keyOf = (type: "ed25519" | "rsa") =>
|
||||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||||
const named = keys.filter((k) => k.name);
|
const named = keys.filter((k) => k.name);
|
||||||
|
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
|
||||||
const keyLabel = (keyId: string | null | undefined) => {
|
const keyLabel = (keyId: string | null | undefined) => {
|
||||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||||
if (!k) return t("gitIdentity.anyKey");
|
if (!k) return t("gitIdentity.anyKey");
|
||||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||||
};
|
};
|
||||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
// "Add another key" in a tab makes the next key of that tab's type at once,
|
||||||
const makeNamed = async () => {
|
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
|
||||||
if (!newKey) return;
|
// account name of the user's own goes in its step 3.
|
||||||
const ok = await run(
|
const makeNamed = (type: "ed25519" | "rsa") => {
|
||||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
const prefix = type === "rsa" ? "aws" : "git";
|
||||||
|
let n = 2;
|
||||||
|
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
|
||||||
|
return run(
|
||||||
|
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
|
||||||
|
t("gitIdentity.keyMade")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const addKey = (type: "ed25519" | "rsa") => (
|
||||||
|
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.addKey")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const replaceNamed = async (k: Key) => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.replaceConfirm"),
|
||||||
|
confirmLabel: t("gitIdentity.replace"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(
|
||||||
|
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
|
||||||
t("gitIdentity.keyMade")
|
t("gitIdentity.keyMade")
|
||||||
);
|
);
|
||||||
if (ok) setNewKey(null);
|
|
||||||
};
|
};
|
||||||
const removeNamed = async (keyId: string) => {
|
const removeNamed = async (keyId: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -304,29 +408,60 @@ export function GitIdentitySettings() {
|
|||||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||||
});
|
});
|
||||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
// A login is shown under the key it names; one naming no key (or one gone)
|
||||||
const saveAws = async () => {
|
// under the default key.
|
||||||
let user = aws.keyId.trim();
|
const namedIds = new Set(named.map((k) => k.id));
|
||||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
|
||||||
|
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
|
||||||
|
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
|
||||||
|
let user = v.keyId.trim();
|
||||||
|
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||||
if (m?.[1] && m[2]) {
|
if (m?.[1] && m[2]) {
|
||||||
user = m[1];
|
user = m[1];
|
||||||
host = m[2].toLowerCase();
|
host = m[2].toLowerCase();
|
||||||
}
|
}
|
||||||
const alias = aws.alias.trim().toLowerCase();
|
// A named key's account goes by the key's name unless another is typed.
|
||||||
const ok = await run(
|
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||||
|
return run(
|
||||||
() =>
|
() =>
|
||||||
api.gitIdentity.setSshUser({
|
api.gitIdentity.setSshUser({
|
||||||
host,
|
host,
|
||||||
user,
|
user,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(aws.key ? { key: aws.key } : {}),
|
...(k?.id ? { key: k.id } : {}),
|
||||||
}),
|
}),
|
||||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||||
);
|
);
|
||||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
|
||||||
};
|
};
|
||||||
|
const saveGitHost = (host: string, k: Key) => {
|
||||||
|
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||||
|
return run(
|
||||||
|
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
|
||||||
|
t("gitIdentity.otherSaved", { host: k.name })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/** The logins of one key, each with its delete button. */
|
||||||
|
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
|
||||||
|
rows.map((r) => (
|
||||||
|
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||||
|
{text(r)}
|
||||||
|
</span>
|
||||||
|
{id?.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||||
|
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||||
const saveOther = async () => {
|
const saveOther = async () => {
|
||||||
let host = other.host.trim();
|
let host = other.host.trim();
|
||||||
@@ -346,7 +481,7 @@ export function GitIdentitySettings() {
|
|||||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||||
};
|
};
|
||||||
/** The key picker of a login form: every key, or one. */
|
/** The key picker of a login form: every key, or one. */
|
||||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
const keySelect = (value: string, onChange: (v: string) => void) => (
|
||||||
<select
|
<select
|
||||||
aria-label={t("gitIdentity.loginKey")}
|
aria-label={t("gitIdentity.loginKey")}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -355,7 +490,7 @@ export function GitIdentitySettings() {
|
|||||||
>
|
>
|
||||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||||
{keys
|
{keys
|
||||||
.filter((k) => k.id && (!only || k.type === only))
|
.filter((k) => k.id)
|
||||||
.map((k) => (
|
.map((k) => (
|
||||||
<option key={k.id} value={k.id}>
|
<option key={k.id} value={k.id}>
|
||||||
{keyLabel(k.id)}
|
{keyLabel(k.id)}
|
||||||
@@ -396,17 +531,6 @@ export function GitIdentitySettings() {
|
|||||||
const sshTabs = [
|
const sshTabs = [
|
||||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||||
...(named.length > 0 || id.writable
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "more",
|
|
||||||
label: named.length
|
|
||||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
|
||||||
: t("gitIdentity.tabMore"),
|
|
||||||
Icon: KeySquare,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||||
: []),
|
: []),
|
||||||
@@ -498,228 +622,97 @@ export function GitIdentitySettings() {
|
|||||||
onSelect={setSshTab}
|
onSelect={setSshTab}
|
||||||
>
|
>
|
||||||
{activeSshTab === "git" && (
|
{activeSshTab === "git" && (
|
||||||
<KeyCard
|
<div className="space-y-2">
|
||||||
title={t("gitIdentity.gitHubTitle")}
|
<KeyCard
|
||||||
sub={t("gitIdentity.gitHubSub")}
|
title={t("gitIdentity.gitHubTitle")}
|
||||||
type="ed25519"
|
sub={t("gitIdentity.gitHubSub")}
|
||||||
k={keyOf("ed25519")}
|
label="ED25519"
|
||||||
id={id}
|
k={keyOf("ed25519")}
|
||||||
busy={busy}
|
writable={id.writable}
|
||||||
onGenerate={generate}
|
busy={busy}
|
||||||
onRemove={removeKey}
|
onGenerate={() => generate("ed25519")}
|
||||||
>
|
onRemove={() => removeKey("ed25519")}
|
||||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
>
|
||||||
<Step n={3}>
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
{t("gitIdentity.gitHubStepClone")}
|
</KeyCard>
|
||||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
{namedOf("ed25519").map((k) => {
|
||||||
</Step>
|
const rows = rowsOf(otherRows, k);
|
||||||
</KeyCard>
|
return (
|
||||||
|
<KeyCard
|
||||||
|
key={k.id}
|
||||||
|
title={k.name!}
|
||||||
|
sub={t("gitIdentity.namedGitSub")}
|
||||||
|
label={k.name!}
|
||||||
|
k={k}
|
||||||
|
writable={id.writable}
|
||||||
|
busy={busy}
|
||||||
|
onGenerate={() => replaceNamed(k)}
|
||||||
|
onRemove={() => removeNamed(k.id!)}
|
||||||
|
>
|
||||||
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||||
|
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||||
|
{id.writable && rows.length === 0 && (
|
||||||
|
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||||
|
)}
|
||||||
|
</Step>
|
||||||
|
</KeyCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{id.writable &&
|
||||||
|
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
|
||||||
|
addKey("ed25519")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSshTab === "aws" && (
|
{activeSshTab === "aws" && (
|
||||||
<KeyCard
|
<div className="space-y-2">
|
||||||
title={t("gitIdentity.awsTitle")}
|
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
|
||||||
sub={t("gitIdentity.awsSub")}
|
const isNamed = i > 0 && !!k;
|
||||||
type="rsa"
|
const rows = rowsOf(awsRows, isNamed ? k : undefined);
|
||||||
k={keyOf("rsa")}
|
return (
|
||||||
id={id}
|
<KeyCard
|
||||||
busy={busy}
|
key={k?.id ?? "id_rsa"}
|
||||||
onGenerate={generate}
|
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
|
||||||
onRemove={removeKey}
|
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
|
||||||
>
|
label={isNamed ? k!.name! : "RSA"}
|
||||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
k={k}
|
||||||
<Step n={3}>
|
writable={id.writable}
|
||||||
{t("gitIdentity.awsStepKeyId")}
|
busy={busy}
|
||||||
{id.sshUsers === undefined ? null : (
|
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
|
||||||
<>
|
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
|
||||||
{awsRows.map((r) => (
|
|
||||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
|
||||||
{r.alias ? `${r.alias} · ` : ""}
|
|
||||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
|
||||||
</span>
|
|
||||||
{id.writable && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${BTN} hover:text-status-danger`}
|
|
||||||
disabled={busy}
|
|
||||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
|
||||||
onClick={() =>
|
|
||||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{id.writable && (
|
|
||||||
<form
|
|
||||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void saveAws();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
aria-label={t("gitIdentity.awsKeyId")}
|
|
||||||
placeholder="APKA…"
|
|
||||||
autoComplete="off"
|
|
||||||
value={aws.keyId}
|
|
||||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
|
||||||
className={`${INPUT} font-mono`}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
aria-label={t("gitIdentity.awsRegion")}
|
|
||||||
value={aws.region}
|
|
||||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
|
||||||
className="select !py-1 !text-xs"
|
|
||||||
>
|
|
||||||
{AWS_REGIONS.map((r) => (
|
|
||||||
<option key={r} value={r}>
|
|
||||||
{r}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
aria-label={t("gitIdentity.alias")}
|
|
||||||
title={t("gitIdentity.aliasHint")}
|
|
||||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
|
||||||
autoComplete="off"
|
|
||||||
value={aws.alias}
|
|
||||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
|
||||||
className={`${INPUT} font-mono`}
|
|
||||||
/>
|
|
||||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
|
||||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
|
||||||
{t("gitIdentity.awsSave")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Step>
|
|
||||||
{awsRows.length > 0 && (
|
|
||||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
|
||||||
{t("gitIdentity.awsReady")}
|
|
||||||
{awsRows.map((r) => (
|
|
||||||
<Code key={r.alias ?? r.host}>
|
|
||||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
|
||||||
</Code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</KeyCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSshTab === "more" && (
|
|
||||||
<section
|
|
||||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
|
||||||
className="space-y-2 rounded border border-border p-2.5"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-xs font-medium text-fg-primary">
|
|
||||||
{t("gitIdentity.moreKeysTitle")}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
|
||||||
</div>
|
|
||||||
{id.writable && !newKey && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||||
{t("gitIdentity.newKey")}
|
<Step n={3}>
|
||||||
</button>
|
{t("gitIdentity.awsStepKeyId")}
|
||||||
)}
|
{id.sshUsers === undefined ? null : (
|
||||||
</div>
|
<>
|
||||||
{newKey && (
|
{loginRows(
|
||||||
<form
|
rows,
|
||||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
(r) =>
|
||||||
onSubmit={(e) => {
|
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||||
e.preventDefault();
|
)}
|
||||||
void makeNamed();
|
{id.writable && (
|
||||||
}}
|
<AwsLoginForm
|
||||||
>
|
busy={busy}
|
||||||
<input
|
named={isNamed ? k!.name! : undefined}
|
||||||
aria-label={t("gitIdentity.keyName")}
|
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
/>
|
||||||
autoComplete="off"
|
)}
|
||||||
autoFocus
|
</>
|
||||||
value={newKey.name}
|
)}
|
||||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
</Step>
|
||||||
className={`${INPUT} font-mono`}
|
</KeyCard>
|
||||||
/>
|
);
|
||||||
<select
|
})}
|
||||||
aria-label={t("gitIdentity.keyType")}
|
{id.writable && keys.some((k) => k.type === "rsa") && (
|
||||||
value={newKey.type}
|
<>
|
||||||
onChange={(e) =>
|
{addKey("rsa")}
|
||||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||||
}
|
</>
|
||||||
className="select !py-1 !text-xs"
|
|
||||||
>
|
|
||||||
<option value="rsa">RSA 4096 (AWS)</option>
|
|
||||||
<option value="ed25519">ED25519</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
|
||||||
{t("gitIdentity.generate")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
|
||||||
{t("gitCreds.cancel")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
{named.map((k) => (
|
</div>
|
||||||
<div
|
|
||||||
key={k.id}
|
|
||||||
className="space-y-1 rounded border border-border-light p-2"
|
|
||||||
data-testid="git-named-key"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
|
||||||
{k.name}
|
|
||||||
</span>
|
|
||||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
|
||||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
|
||||||
{k.fingerprint}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
onClick={() => void copyText(k.publicKey)}
|
|
||||||
>
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
{t("gitIdentity.copy")}
|
|
||||||
</button>
|
|
||||||
{id.writable && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${BTN} hover:text-status-danger`}
|
|
||||||
disabled={busy}
|
|
||||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
|
||||||
onClick={() => void removeNamed(k.id!)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
|
||||||
value={k.publicKey}
|
|
||||||
rows={2}
|
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
|
||||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSshTab === "other" && (
|
{activeSshTab === "other" && (
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* @file NineRouterUpdate.tsx
|
||||||
|
* @description **Update 9router** on the AI Proxy page (every user's; beside
|
||||||
|
* Config 9router): a button — dotted when npm has a newer 9router — and a
|
||||||
|
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
|
||||||
|
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
|
||||||
|
* again hidden (tray, no browser) — the last only when it was running before.
|
||||||
|
* The dialog shows why an update cannot run right now (the server's `blocker`,
|
||||||
|
* with the command to run by hand when sudo needs a password) and follows the
|
||||||
|
* job by polling until it ends. Closing the dialog does not stop the job.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react";
|
||||||
|
import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
|
function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) {
|
||||||
|
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
|
||||||
|
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
|
||||||
|
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
|
||||||
|
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||||
|
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NineRouterUpdate() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(open, dialogRef, () => setOpen(false));
|
||||||
|
|
||||||
|
const load = useCallback(async (fresh = false) => {
|
||||||
|
try {
|
||||||
|
setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const running = status?.job?.status === "running";
|
||||||
|
// Follow a running job — also one started from another tab.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
const timer = window.setInterval(() => void load(), POLL_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [running, load]);
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setError(null);
|
||||||
|
setOpen(true);
|
||||||
|
void load(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setStarting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const job = await api.aiProxies.nineRouterUpdate();
|
||||||
|
setStatus((s) => (s ? { ...s, job } : s));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
void load(true);
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!status?.installed) return null;
|
||||||
|
const job = status.job;
|
||||||
|
const blocker = status.blocker;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openDialog}
|
||||||
|
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("aiProxy.update9router")}
|
||||||
|
{status.updateAvailable && !running && (
|
||||||
|
<span
|
||||||
|
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
|
||||||
|
aria-label={t("aiProxy.update9routerAvailable")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="nine-router-update-title"
|
||||||
|
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
id="nine-router-update-title"
|
||||||
|
className="mb-3 text-sm font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
|
{t("aiProxy.update9router")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
|
||||||
|
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
|
||||||
|
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
|
||||||
|
<dd className="text-fg-primary">
|
||||||
|
{status.running
|
||||||
|
? status.managedBy === "systemd"
|
||||||
|
? t("aiProxy.update9routerRunningUnit")
|
||||||
|
: t("aiProxy.update9routerRunning")
|
||||||
|
: t("aiProxy.update9routerStopped")}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="mb-3 text-[11px] text-fg-muted">
|
||||||
|
{status.running
|
||||||
|
? t("aiProxy.update9routerExplain")
|
||||||
|
: t("aiProxy.update9routerExplainStopped")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{job && (
|
||||||
|
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
|
||||||
|
{job.steps.map((s) => (
|
||||||
|
<li key={s.id} className="flex items-start gap-2 text-xs">
|
||||||
|
<span className="mt-0.5">
|
||||||
|
<StepIcon status={s.status} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="text-fg-primary">
|
||||||
|
{t(`aiProxy.update9routerStep.${s.id}`)}
|
||||||
|
</span>
|
||||||
|
{s.message && (
|
||||||
|
<span className="block break-words text-[11px] text-fg-muted">
|
||||||
|
{s.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{job && job.status !== "running" && (
|
||||||
|
<p
|
||||||
|
className={`mb-3 text-xs ${
|
||||||
|
job.status === "done" ? "text-status-success" : "text-status-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status !== "done"
|
||||||
|
? t("aiProxy.update9routerFailed")
|
||||||
|
: job.steps.find((s) => s.id === "relaunch")?.status === "skipped"
|
||||||
|
? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" })
|
||||||
|
: t("aiProxy.update9routerDone", { version: job.to ?? "—" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{job?.log && (
|
||||||
|
<details className="mb-3">
|
||||||
|
<summary className="cursor-pointer text-[11px] text-fg-muted">
|
||||||
|
{t("aiProxy.update9routerLog")}
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
|
||||||
|
{job.log}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blocker && !running && blocker.code !== "EUPTODATE" && (
|
||||||
|
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
|
||||||
|
<p>{blocker.message}</p>
|
||||||
|
{blocker.command && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
|
||||||
|
{blocker.command}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(blocker.command!)}
|
||||||
|
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
|
||||||
|
aria-label={t("aiProxy.update9routerCopy")}
|
||||||
|
title={t("aiProxy.update9routerCopy")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{blocker?.code === "EUPTODATE" && !running && (
|
||||||
|
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t("aiProxy.update9routerClose")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={Boolean(blocker) || running || starting}
|
||||||
|
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{t("aiProxy.update9routerStart")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
|
||||||
|
* mode, every user picks which account of the shared account pool their own
|
||||||
|
* new sessions run on, per tool, among the accounts their pool group may use
|
||||||
|
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||||
|
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
|
||||||
|
* Claude login goes into the user's home at once, so running panes follow it;
|
||||||
|
* Codex and Cursor take it at the next session. Renders nothing in local mode
|
||||||
|
* or when the pool holds no account the user may use.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Lock } from "lucide-react";
|
||||||
|
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
import { SettingsRow, SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
||||||
|
|
||||||
|
export function PoolAccountSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const { local } = usePermissions();
|
||||||
|
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState<PoolTool | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (local) return;
|
||||||
|
api.me
|
||||||
|
.poolAccounts()
|
||||||
|
.then((r) => setTools(r.tools))
|
||||||
|
.catch(() => setTools(null));
|
||||||
|
}, [local]);
|
||||||
|
|
||||||
|
if (local || !tools?.length) return null;
|
||||||
|
|
||||||
|
const choose = async (tool: PoolTool, account: string | null) => {
|
||||||
|
setBusy(tool);
|
||||||
|
try {
|
||||||
|
const r = await api.me.choosePoolAccount(tool, account);
|
||||||
|
setTools((list) =>
|
||||||
|
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
!r.choice
|
||||||
|
? t("poolAccount.automaticSaved")
|
||||||
|
: r.switched === "switched" || r.switched === "kept"
|
||||||
|
? t("poolAccount.switchedNow", { account: r.choice })
|
||||||
|
: t("poolAccount.nextSession", { account: r.choice })
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="pool-account"
|
||||||
|
title={t("poolAccount.title")}
|
||||||
|
description={t("poolAccount.description")}
|
||||||
|
>
|
||||||
|
{tools.map((x) => (
|
||||||
|
<SettingsRow
|
||||||
|
key={x.tool}
|
||||||
|
id={`pool-account-${x.tool}`}
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
|
||||||
|
{LABEL[x.tool]}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
x.pin ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
{t("poolAccount.pinned", { account: x.pin })}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("poolAccount.count", { count: x.accounts.length })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!!x.pin}
|
||||||
|
control={
|
||||||
|
<select
|
||||||
|
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
|
||||||
|
value={x.pin ?? x.choice ?? ""}
|
||||||
|
disabled={!!x.pin || busy === x.tool}
|
||||||
|
onChange={(e) => void choose(x.tool, e.target.value || null)}
|
||||||
|
className="select !py-1 !text-xs max-w-[16rem]"
|
||||||
|
>
|
||||||
|
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
|
||||||
|
{x.accounts.map((a) => (
|
||||||
|
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
|
||||||
|
{[
|
||||||
|
a.name,
|
||||||
|
a.email && a.email !== a.name ? `(${a.email})` : null,
|
||||||
|
a.expired
|
||||||
|
? `— ${t("poolAccount.expired")}`
|
||||||
|
: a.full
|
||||||
|
? `— ${t("poolAccount.full")}`
|
||||||
|
: a.quotaPercent != null
|
||||||
|
? `— ${Math.round(a.quotaPercent)}%`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,8 +16,7 @@ import { api } from "../../lib/api";
|
|||||||
import type { PushTokenStatus } from "../../lib/api";
|
import type { PushTokenStatus } from "../../lib/api";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { SettingsSection } from "./SettingsRow";
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
|
|
||||||
|
|
||||||
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
|
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
|
||||||
function forwarder(origin: string) {
|
function forwarder(origin: string) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
|||||||
import { CopyButton } from "../event-views/primitives";
|
import { CopyButton } from "../event-views/primitives";
|
||||||
import { snapshotUrl } from "../SnapshotButton";
|
import { snapshotUrl } from "../SnapshotButton";
|
||||||
import { SettingsSection } from "./SettingsRow";
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
|
||||||
export function SnapshotSettings() {
|
export function SnapshotSettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -116,9 +117,7 @@ export function SnapshotSettings() {
|
|||||||
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
|
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
|
||||||
{t("snapshots.open")}
|
{t("snapshots.open")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem onSelect={pick((s) => void copyText(snapshotUrl(s.token)))}>
|
||||||
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
|
|
||||||
>
|
|
||||||
{t("snapshots.copyLink")}
|
{t("snapshots.copyLink")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
|
|||||||
@@ -0,0 +1,808 @@
|
|||||||
|
/**
|
||||||
|
* @file ToolProfilesSettings — Settings → AI tools → Profiles: every AI tool's
|
||||||
|
* login profiles in ONE place, a tab per tool. Claude Code's are cc-profiles
|
||||||
|
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
|
||||||
|
* warning — this panel used to be a modal on the Claude Code page); Codex and
|
||||||
|
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
|
||||||
|
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
|
||||||
|
* each Claude/Codex profile to the account pool in Administration → Users,
|
||||||
|
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
|
||||||
|
* pool's Add dialog on it), and shows how many accounts the pool holds.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
Save,
|
||||||
|
ShieldAlert,
|
||||||
|
Terminal,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type AccountPool,
|
||||||
|
type CcAccountIdentity,
|
||||||
|
type CcProfile,
|
||||||
|
type CcProfileUsage,
|
||||||
|
type CcUsageWindow,
|
||||||
|
type ToolProfile,
|
||||||
|
type ToolProfileIdentity,
|
||||||
|
type ToolProfilesTool,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
import { SkeletonRows } from "../config/configPrimitives";
|
||||||
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
/** The tools the panel covers, in tab order, with the command that signs one in. */
|
||||||
|
const TOOLS: { id: string; label: string; login: string }[] = [
|
||||||
|
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
||||||
|
{ id: "codex", label: "Codex", login: "codex login" },
|
||||||
|
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
||||||
|
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
||||||
|
];
|
||||||
|
/** Tools the account pool takes (server/routes/accounts.js). */
|
||||||
|
const POOL_TOOLS = new Set(["claude", "codex"]);
|
||||||
|
|
||||||
|
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
|
||||||
|
const btnCls =
|
||||||
|
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
|
||||||
|
|
||||||
|
export function ToolProfilesSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const aiTools = useAiTools();
|
||||||
|
const { local, seesAll } = usePermissions();
|
||||||
|
const shown = TOOLS.filter((x) => {
|
||||||
|
const tool = aiTools.byId(x.id);
|
||||||
|
if (x.id === "claude") return tool?.enabled !== false;
|
||||||
|
return !!tool && tool.installed && tool.enabled !== false;
|
||||||
|
});
|
||||||
|
const [tab, setTab] = useState<string>("claude");
|
||||||
|
const current = shown.find((x) => x.id === tab) ?? shown[0];
|
||||||
|
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||||
|
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||||
|
|
||||||
|
const loadOthers = useCallback(
|
||||||
|
() =>
|
||||||
|
api.toolProfiles
|
||||||
|
.list()
|
||||||
|
.then((r) => setOthers(r.tools))
|
||||||
|
.catch(() => setOthers([])),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOthers();
|
||||||
|
}, [loadOthers]);
|
||||||
|
// The pool is a server-mode, staff-only read.
|
||||||
|
useEffect(() => {
|
||||||
|
if (local || !seesAll) return;
|
||||||
|
api.accountPool
|
||||||
|
.list()
|
||||||
|
.then(setPool)
|
||||||
|
.catch(() => setPool(null));
|
||||||
|
}, [local, seesAll]);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
const poolCount = (tool: string) =>
|
||||||
|
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="tool-profiles"
|
||||||
|
title={t("toolProfiles.title")}
|
||||||
|
description={t("toolProfiles.description")}
|
||||||
|
>
|
||||||
|
<div role="tablist" aria-label={t("toolProfiles.title")} className="flex flex-wrap gap-1.5">
|
||||||
|
{shown.map((x) => (
|
||||||
|
<button
|
||||||
|
key={x.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={x.id === current.id}
|
||||||
|
onClick={() => setTab(x.id)}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs ${
|
||||||
|
x.id === current.id
|
||||||
|
? "border-accent/40 bg-accent/10 text-accent"
|
||||||
|
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AiToolIcon tool={{ id: x.id, label: x.label }} className="h-3.5 w-3.5" />
|
||||||
|
{x.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" className="mt-3 space-y-3">
|
||||||
|
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||||
|
<PoolLine count={poolCount(current.id)!} />
|
||||||
|
)}
|
||||||
|
{current.id === "claude" ? (
|
||||||
|
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
||||||
|
) : (
|
||||||
|
<OtherProfiles
|
||||||
|
key={current.id}
|
||||||
|
tool={current.id}
|
||||||
|
label={current.label}
|
||||||
|
login={current.login}
|
||||||
|
state={others?.find((o) => o.tool === current.id) ?? null}
|
||||||
|
loading={others === null}
|
||||||
|
pooled={!local && POOL_TOOLS.has(current.id)}
|
||||||
|
onChanged={loadOthers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "The pool holds N accounts" + the way to it (Administration → Users). */
|
||||||
|
function PoolLine({ count }: { count: number }) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-fg-secondary">
|
||||||
|
<KeyRound className="h-3.5 w-3.5 text-fg-muted" />
|
||||||
|
<span className="flex-1">{t("toolProfiles.poolCount", { count })}</span>
|
||||||
|
<Link to="/admin#account-pool" className="text-accent hover:underline">
|
||||||
|
{t("toolProfiles.openPool")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The link that opens the pool's Add dialog on one saved profile (admins only). */
|
||||||
|
function MoveToPool({ tool, name }: { tool: string; name: string }) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/admin?${new URLSearchParams({ poolAdd: tool, poolProfile: name })}#account-pool`}
|
||||||
|
title={t("toolProfiles.moveToPoolHint")}
|
||||||
|
className="h-7 text-[11px] px-2 rounded-md text-fg-muted hover:text-accent hover:bg-surface-3 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="h-3 w-3" />
|
||||||
|
{t("toolProfiles.moveToPool")}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginHint({ text, command }: { text: string; command: string }) {
|
||||||
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-fg-muted mb-1">{text}</div>
|
||||||
|
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
|
||||||
|
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||||
|
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (await copyText(command, true)) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
|
||||||
|
title={t("snippet.copy")}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||||
|
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A profile's label, renamed in place: click it (or the menu's Rename), type, Enter. */
|
||||||
|
function LabelEdit({
|
||||||
|
label,
|
||||||
|
busy,
|
||||||
|
editing,
|
||||||
|
setEditing,
|
||||||
|
onRename,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
busy: boolean;
|
||||||
|
editing: boolean;
|
||||||
|
setEditing: (on: boolean) => void;
|
||||||
|
onRename: (next: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const [draft, setDraft] = useState(label);
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) setDraft(label);
|
||||||
|
}, [editing, label]);
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={() => setEditing(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
const next = draft.trim();
|
||||||
|
setEditing(false);
|
||||||
|
if (next && next !== label) onRename(next);
|
||||||
|
}}
|
||||||
|
maxLength={60}
|
||||||
|
aria-label={t("profiles.renameLabel")}
|
||||||
|
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
title={t("profiles.renameHint")}
|
||||||
|
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One mutation with a busy key, a toast either way, then a reload. */
|
||||||
|
function useRunner(load: () => Promise<unknown>) {
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const run = useCallback(
|
||||||
|
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||||
|
setBusy(key);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
toast.success(success);
|
||||||
|
await load();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[load]
|
||||||
|
);
|
||||||
|
return { busy, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Codex, Oh My Pi, … ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OtherProfiles({
|
||||||
|
tool,
|
||||||
|
label,
|
||||||
|
login,
|
||||||
|
state,
|
||||||
|
loading,
|
||||||
|
pooled,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
tool: string;
|
||||||
|
label: string;
|
||||||
|
login: string;
|
||||||
|
state: ToolProfilesTool | null;
|
||||||
|
loading: boolean;
|
||||||
|
pooled: boolean;
|
||||||
|
onChanged: () => Promise<unknown>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const { t: tc } = useTranslation("ccConfig");
|
||||||
|
const { busy, run } = useRunner(onChanged);
|
||||||
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (loading) return <SkeletonRows n={2} />;
|
||||||
|
if (!state) return <p className="text-xs text-fg-muted">{t("toolProfiles.noProfiles")}</p>;
|
||||||
|
if (!state.supported) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-fg-muted inline-flex items-start gap-1.5">
|
||||||
|
<ShieldAlert className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{t(`toolProfiles.unsupported.${tool}`, { defaultValue: state.reason ?? "" })}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const items = state.items;
|
||||||
|
const activeSaved = items.some((p) => p.active);
|
||||||
|
const remove = async (p: ToolProfile) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
||||||
|
message: t("toolProfiles.deleteConfirmBody", { tool: label }),
|
||||||
|
confirmLabel: tc("profiles.deleteButton"),
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
void run(
|
||||||
|
`del:${p.name}`,
|
||||||
|
() => api.toolProfiles.remove(tool, p.name),
|
||||||
|
tc("profiles.deleteSuccess", { name: p.label })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[11px] text-fg-muted leading-relaxed">
|
||||||
|
{t(`toolProfiles.subtitle.${tool}`, { defaultValue: "" })}
|
||||||
|
</p>
|
||||||
|
{state.error && (
|
||||||
|
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{state.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`${rowCls} space-y-1`}>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||||
|
{tc("profiles.activeLabel")}
|
||||||
|
</div>
|
||||||
|
{state.active ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-fg-primary">{who(state.active)}</div>
|
||||||
|
<OtherIdentityLine identity={state.active} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-fg-muted">{t("toolProfiles.noActive", { tool: label })}</div>
|
||||||
|
)}
|
||||||
|
{state.active && !activeSaved && (
|
||||||
|
<div className="pt-1.5 space-y-1.5">
|
||||||
|
<div className="text-[11px] text-status-warning">{tc("profiles.unsavedActive")}</div>
|
||||||
|
<button
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void run("save", () => api.toolProfiles.save(tool), tc("profiles.saveSuccess"))
|
||||||
|
}
|
||||||
|
className={btnCls}
|
||||||
|
>
|
||||||
|
<Save className="w-3 h-3" />
|
||||||
|
{busy === "save" ? tc("profiles.saving") : t("toolProfiles.saveButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||||
|
{tc("profiles.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map((p) => (
|
||||||
|
<div key={p.name} data-testid={`profile-row-${tool}-${p.name}`} className={rowCls}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<LabelEdit
|
||||||
|
label={p.label}
|
||||||
|
busy={busy !== null}
|
||||||
|
editing={renaming === p.name}
|
||||||
|
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||||
|
onRename={(next) =>
|
||||||
|
void run(
|
||||||
|
`rename:${p.name}`,
|
||||||
|
() => api.toolProfiles.rename(tool, p.name, next),
|
||||||
|
tc("profiles.renameSuccess", { name: next })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{p.active && <ActiveBadge />}
|
||||||
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">{who(p)}</span>
|
||||||
|
{!p.active && (
|
||||||
|
<button
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
`switch:${p.name}`,
|
||||||
|
() => api.toolProfiles.activate(tool, p.name),
|
||||||
|
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={btnCls}
|
||||||
|
>
|
||||||
|
{busy === `switch:${p.name}`
|
||||||
|
? tc("profiles.switching")
|
||||||
|
: tc("profiles.switchButton")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pooled && <MoveToPool tool={tool} name={p.name} />}
|
||||||
|
{!p.active && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => void remove(p)}
|
||||||
|
title={tc("profiles.deleteButton")}
|
||||||
|
aria-label={tc("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>
|
||||||
|
<OtherIdentityLine identity={p} />
|
||||||
|
{p.savedAt && (
|
||||||
|
<div className="text-[10px] text-fg-muted mt-1">
|
||||||
|
{tc("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const who = (id: ToolProfileIdentity) => id.email || id.detail || id.kind;
|
||||||
|
|
||||||
|
function OtherIdentityLine({ identity }: { identity: ToolProfileIdentity }) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const parts = [
|
||||||
|
t(`toolProfiles.kind.${identity.kind}`, { defaultValue: identity.kind }),
|
||||||
|
identity.email ? identity.detail : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveBadge() {
|
||||||
|
const { t } = useTranslation("ccConfig");
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||||
|
{t("profiles.activeBadge")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Claude Code ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
|
||||||
|
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
|
||||||
|
// subagents, memory, plugins, marketplaces and settings all stay exactly where
|
||||||
|
// they are. The dashboard cannot log a new account in (only `claude auth login`
|
||||||
|
// can mint tokens), so adding a profile is always "log in, then snapshot what
|
||||||
|
// the CLI wrote" — a save button on the live account, never a name field: the
|
||||||
|
// server derives the identifier, and the user edits the label in place. The
|
||||||
|
// save button only appears while the live account is unsaved, because one
|
||||||
|
// account is one profile.
|
||||||
|
|
||||||
|
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||||
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||||
|
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||||
|
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
|
||||||
|
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
/** Quota per profile, keyed by `name`, fetched for every profile once the
|
||||||
|
* list arrives: the live account is read from the CLI and never refreshed,
|
||||||
|
* any other profile only when its token has actually expired. */
|
||||||
|
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||||
|
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||||
|
/** Per profile, so one slow account does not freeze the other rows. */
|
||||||
|
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
||||||
|
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||||
|
const autoFetched = useRef<Set<string>>(new Set());
|
||||||
|
const ctx = useContextMenu<CcProfile>();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.ccConfig.profiles();
|
||||||
|
setItems(r.items);
|
||||||
|
setActive(r.active);
|
||||||
|
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "unknown");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const { busy, run } = useRunner(load);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const checkUsage = useCallback(async (name: string) => {
|
||||||
|
setUsageBusy((prev) => ({ ...prev, [name]: true }));
|
||||||
|
setUsageError((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await api.ccConfig.profileUsage(name);
|
||||||
|
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setUsageError((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: err instanceof Error ? err.message : "unknown error",
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setUsageBusy((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Every profile's quota, once per profile per mount: a retry is the row's
|
||||||
|
// own button, so a failed account is not re-asked on every render.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!items) return;
|
||||||
|
for (const p of items) {
|
||||||
|
if (autoFetched.current.has(p.name)) continue;
|
||||||
|
autoFetched.current.add(p.name);
|
||||||
|
void checkUsage(p.name);
|
||||||
|
}
|
||||||
|
}, [items, checkUsage]);
|
||||||
|
|
||||||
|
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||||
|
/** How many saved profiles hold the same account as `p`. */
|
||||||
|
const copies = (p: CcProfile) =>
|
||||||
|
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
||||||
|
/** Deletable unless it is the ONLY saved copy of the live account. */
|
||||||
|
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
|
||||||
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
|
const switchTo = (p: CcProfile) =>
|
||||||
|
void run(
|
||||||
|
`switch:${p.name}`,
|
||||||
|
() => api.ccConfig.activateProfile(p.name),
|
||||||
|
t("profiles.switchSuccess", { name: p.label })
|
||||||
|
);
|
||||||
|
const remove = async (p: CcProfile) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("profiles.deleteConfirmTitle", { name: p.label }),
|
||||||
|
message: t("profiles.deleteConfirmBody"),
|
||||||
|
confirmLabel: t("profiles.deleteButton"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
void run(
|
||||||
|
`del:${p.name}`,
|
||||||
|
() => api.ccConfig.deleteProfile(p.name),
|
||||||
|
t("profiles.deleteSuccess", { name: p.label })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const pick = (fn: (p: CcProfile) => void) => () => {
|
||||||
|
const p = ctx.menu!.item;
|
||||||
|
ctx.close();
|
||||||
|
fn(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||||
|
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
|
||||||
|
{mismatchEmail && (
|
||||||
|
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||||
|
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
{t("profiles.mismatch", {
|
||||||
|
config: mismatchEmail,
|
||||||
|
owner: active?.email || active?.accountUuid || "?",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`${rowCls} space-y-1`}>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||||
|
{t("profiles.activeLabel")}
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-fg-primary">
|
||||||
|
{active.email || active.displayName || active.accountUuid}
|
||||||
|
</div>
|
||||||
|
<IdentityLine identity={active} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||||
|
)}
|
||||||
|
{active && !activeSaved && (
|
||||||
|
<div className="pt-1.5 space-y-1.5">
|
||||||
|
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||||
|
<button
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
|
||||||
|
}
|
||||||
|
className={btnCls}
|
||||||
|
>
|
||||||
|
<Save className="w-3 h-3" />
|
||||||
|
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items === null && !error && <SkeletonRows n={3} />}
|
||||||
|
{items !== null && items.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">
|
||||||
|
{t("profiles.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items?.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.name}
|
||||||
|
data-testid={`profile-row-${p.name}`}
|
||||||
|
onContextMenu={(e) => ctx.open(e, p)}
|
||||||
|
className={rowCls}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<LabelEdit
|
||||||
|
label={p.label}
|
||||||
|
busy={busy !== null}
|
||||||
|
editing={renaming === p.name}
|
||||||
|
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||||
|
onRename={(next) =>
|
||||||
|
void run(
|
||||||
|
`rename:${p.name}`,
|
||||||
|
() => api.ccConfig.renameProfile(p.name, next),
|
||||||
|
t("profiles.renameSuccess", { name: next })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{p.active && <ActiveBadge />}
|
||||||
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
|
{p.email || p.displayName || p.accountUuid}
|
||||||
|
</span>
|
||||||
|
{!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>
|
||||||
|
<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 && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||||
|
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
|
||||||
|
{!ctx.menu.item.active && (
|
||||||
|
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||||
|
{t("profiles.switchButton")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
|
||||||
|
onSelect={pick((p) => void checkUsage(p.name))}
|
||||||
|
>
|
||||||
|
{t("profiles.usageCheck")}
|
||||||
|
</MenuItem>
|
||||||
|
{deletable(ctx.menu.item) && (
|
||||||
|
<>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
disabled={busy !== null}
|
||||||
|
testId="profile-menu-delete"
|
||||||
|
onSelect={pick((p) => void remove(p))}
|
||||||
|
>
|
||||||
|
{t("profiles.deleteButton")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LoginHint text={t("profiles.loginHint")} command="claude auth login" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||||
|
* reports — the 5-hour session allowance and the 7-day one. Reading a saved
|
||||||
|
* profile's usage may refresh its OAuth token (rotating it on disk), so it
|
||||||
|
* happens once per mount or click, never on a poll.
|
||||||
|
*/
|
||||||
|
function ProfileUsageLine({
|
||||||
|
usage,
|
||||||
|
error,
|
||||||
|
busy,
|
||||||
|
disabled,
|
||||||
|
onCheck,
|
||||||
|
}: {
|
||||||
|
usage?: CcProfileUsage;
|
||||||
|
error?: string;
|
||||||
|
busy: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onCheck: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||||
|
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||||
|
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||||
|
w && w.resetsAt
|
||||||
|
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||||
|
<span className="text-status-danger">{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={onCheck}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("profiles.usageRetry")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!usage) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onCheck}
|
||||||
|
disabled={disabled}
|
||||||
|
title={t("profiles.usageHint")}
|
||||||
|
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||||
|
<span title={resetTitle(usage.fiveHour)}>
|
||||||
|
{t("profiles.usageFiveHour")}{" "}
|
||||||
|
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||||
|
</span>
|
||||||
|
<span title={resetTitle(usage.sevenDay)}>
|
||||||
|
{t("profiles.usageSevenDay")}{" "}
|
||||||
|
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onCheck}
|
||||||
|
disabled={disabled}
|
||||||
|
className="hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||||
|
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||||
|
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
||||||
|
Boolean
|
||||||
|
);
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||||
|
}
|
||||||
@@ -78,8 +78,40 @@ describe("AccountSettings", () => {
|
|||||||
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** jsdom never loads an image: play the browser's answer to `src`. */
|
||||||
|
const stubImage = (loads: boolean) =>
|
||||||
|
vi.stubGlobal(
|
||||||
|
"Image",
|
||||||
|
class {
|
||||||
|
naturalWidth = loads ? 64 : 0;
|
||||||
|
referrerPolicy = "";
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
set src(_v: string) {
|
||||||
|
setTimeout(() => (loads ? this.onload?.() : this.onerror?.()), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("refuses a link this browser cannot load as an image, and saves nothing", async () => {
|
||||||
|
const ue = userEvent.setup();
|
||||||
|
stubImage(false);
|
||||||
|
render(<AccountSettings />);
|
||||||
|
await screen.findByTestId("account-card");
|
||||||
|
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
|
||||||
|
await ue.type(
|
||||||
|
screen.getByTestId("account-avatar-url-input"),
|
||||||
|
"https://chat.example/api/v4/users/x/image{Enter}"
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("account-avatar-url-input")).toBeTruthy());
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(me.setAvatarFromUrl).not.toHaveBeenCalled();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
it("sets the avatar from a link and closes the field", async () => {
|
it("sets the avatar from a link and closes the field", async () => {
|
||||||
const ue = userEvent.setup();
|
const ue = userEvent.setup();
|
||||||
|
stubImage(true);
|
||||||
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
|
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
|
||||||
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
|
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
|
||||||
render(<AccountSettings />);
|
render(<AccountSettings />);
|
||||||
@@ -92,6 +124,7 @@ describe("AccountSettings", () => {
|
|||||||
"/api/me/avatar?v=2026-10-21T00:00:00Z"
|
"/api/me/avatar?v=2026-10-21T00:00:00Z"
|
||||||
);
|
);
|
||||||
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the account's own information", async () => {
|
it("shows the account's own information", async () => {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const VIEW: AiToolsView = {
|
|||||||
tools: [
|
tools: [
|
||||||
tool("claude", "Claude Code"),
|
tool("claude", "Claude Code"),
|
||||||
tool("gemini", "Gemini"),
|
tool("gemini", "Gemini"),
|
||||||
tool("grok", "Grok", { enabled: false }),
|
tool("goose", "Goose", { enabled: false }),
|
||||||
tool("aider", "Aider", { installed: false, path: null }),
|
tool("aider", "Aider", { installed: false, path: null }),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
|
|||||||
await user.click(within(gemini).getByText("Set default"));
|
await user.click(within(gemini).getByText("Set default"));
|
||||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||||
// A disabled tool cannot become the default.
|
// A disabled tool cannot become the default.
|
||||||
const grok = screen.getByTestId("ai-tool-grok");
|
const goose = screen.getByTestId("ai-tool-goose");
|
||||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
expect(within(goose).getByText("Set default")).toBeDisabled();
|
||||||
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes detection", async () => {
|
it("refreshes detection", async () => {
|
||||||
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
|
|||||||
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
|
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
|
||||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||||
// A disabled tool cannot become the default from the menu either.
|
// A disabled tool cannot become the default from the menu either.
|
||||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-goose") });
|
||||||
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
|
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
describe("AiToolIcon", () => {
|
describe("AiToolIcon", () => {
|
||||||
it("retries the icon when the same instance shows another tool", async () => {
|
it("retries the icon when the same instance shows another tool", async () => {
|
||||||
const { AiToolIcon } = await import("../../AiToolIcon");
|
const { AiToolIcon } = await import("../../AiToolIcon");
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
|
||||||
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
|
|
||||||
);
|
|
||||||
fireEvent.error(container.querySelector("img")!);
|
fireEvent.error(container.querySelector("img")!);
|
||||||
expect(screen.getByText("O")).toBeInTheDocument();
|
expect(screen.getByText("A")).toBeInTheDocument();
|
||||||
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
|
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
|
||||||
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
|
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @file ChangesPanelSettings.test.tsx
|
||||||
|
* @description Settings → Git → Changes panel: the inner-repositories switch
|
||||||
|
* starts off, turning it on stores `all` in the synced account preference,
|
||||||
|
* and turning it off again removes the key (back to the default).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { ChangesPanelSettings } from "../ChangesPanelSettings";
|
||||||
|
import { INNER_REPOS_KEY, readInnerReposMode } from "../../../lib/innerReposPref";
|
||||||
|
|
||||||
|
describe("ChangesPanelSettings", () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("is off by default and toggles the account preference", () => {
|
||||||
|
render(<ChangesPanelSettings />);
|
||||||
|
const sw = screen.getByRole("switch", {
|
||||||
|
name: /Show the repositories inside the workspace folder/,
|
||||||
|
});
|
||||||
|
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||||
|
expect(readInnerReposMode()).toBe("own");
|
||||||
|
|
||||||
|
fireEvent.click(sw);
|
||||||
|
expect(localStorage.getItem(INNER_REPOS_KEY)).toBe("all");
|
||||||
|
expect(sw).toHaveAttribute("aria-checked", "true");
|
||||||
|
|
||||||
|
fireEvent.click(sw);
|
||||||
|
expect(localStorage.getItem(INNER_REPOS_KEY)).toBeNull();
|
||||||
|
expect(readInnerReposMode()).toBe("own");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||||
* 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; a named key
|
* the sections never mix; "Add another key" makes a named key as one more
|
||||||
* is made beside the default ones, and a second AWS account is saved under
|
* card in its own tab, a second AWS account is saved from that card under
|
||||||
* its own name with the one key it uses.
|
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||||
* @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";
|
||||||
@@ -156,7 +156,6 @@ describe("GitIdentitySettings", () => {
|
|||||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||||
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||||
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
|
||||||
// A CodeCommit host never shows up again under "other hosts".
|
// A CodeCommit host never shows up again under "other hosts".
|
||||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||||
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||||
@@ -216,9 +215,9 @@ describe("GitIdentitySettings", () => {
|
|||||||
fingerprint: "SHA256:r",
|
fingerprint: "SHA256:r",
|
||||||
};
|
};
|
||||||
const PROD = {
|
const PROD = {
|
||||||
id: "id_rsa_aws-prod",
|
id: "id_rsa_aws-2",
|
||||||
type: "rsa" as const,
|
type: "rsa" as const,
|
||||||
name: "aws-prod",
|
name: "aws-2",
|
||||||
publicKey: "ssh-rsa B prod",
|
publicKey: "ssh-rsa B prod",
|
||||||
fingerprint: "SHA256:p",
|
fingerprint: "SHA256:p",
|
||||||
};
|
};
|
||||||
@@ -230,45 +229,77 @@ describe("GitIdentitySettings", () => {
|
|||||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
view({
|
view({
|
||||||
sshKeys: [RSA, PROD],
|
sshKeys: [RSA, PROD],
|
||||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||||
view({ sshKeys: [RSA], sshUsers: [] })
|
view({ sshKeys: [RSA], sshUsers: [] })
|
||||||
);
|
);
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
// Only an RSA key: the AWS tab opens first.
|
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
|
||||||
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||||
await tab("More keys");
|
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
|
||||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
// One click makes the key — nothing to type before it.
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
|
||||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
const prod = await screen.findByRole("region", { name: "aws-2" });
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
|
||||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
// Another key can be added below it.
|
||||||
|
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||||
|
|
||||||
await tab("AWS CodeCommit");
|
// Its own step 3: the account name defaults to the key's name.
|
||||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
|
||||||
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
|
||||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
|
||||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
host: H,
|
host: H,
|
||||||
user: "APKA2",
|
user: "APKA2",
|
||||||
alias: "aws-prod",
|
alias: "aws-2",
|
||||||
key: "id_rsa_aws-prod",
|
key: "id_rsa_aws-2",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
// The default key's card does not list the named key's login.
|
||||||
|
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||||
|
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||||
|
|
||||||
await tab("More keys (1)");
|
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
|
||||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
});
|
||||||
await waitFor(() =>
|
|
||||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
it("adds a second GitHub key in its tab and ties it to its host", async () => {
|
||||||
|
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||||
|
const WORK = {
|
||||||
|
id: "id_ed25519_git-2",
|
||||||
|
type: "ed25519" as const,
|
||||||
|
name: "git-2",
|
||||||
|
publicKey: "ssh-ed25519 W work",
|
||||||
|
fingerprint: "SHA256:w",
|
||||||
|
};
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
|
||||||
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||||
|
view({ sshKeys: [ED, WORK], sshUsers: [] })
|
||||||
);
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
|
view({
|
||||||
|
sshKeys: [ED, WORK],
|
||||||
|
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
|
||||||
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
|
||||||
|
const work = await screen.findByRole("region", { name: "git-2" });
|
||||||
|
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
|
||||||
|
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
|
||||||
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
|
host: "github.com",
|
||||||
|
user: "git",
|
||||||
|
alias: "git-2",
|
||||||
|
key: "id_ed25519_git-2",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await within(work).findByText("ssh://git-2/ → git@github.com")
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
|
||||||
|
* 9router, a dialog that shows versions and starts the job, the server's
|
||||||
|
* blocker (with the manual command) disabling the start, and the job's steps.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { NineRouterUpdate } from "../NineRouterUpdate";
|
||||||
|
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
|
||||||
|
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
|
||||||
|
|
||||||
|
const base: NineRouterUpdateStatus = {
|
||||||
|
installed: true,
|
||||||
|
version: "0.5.104",
|
||||||
|
latest: "0.5.109",
|
||||||
|
updateAvailable: true,
|
||||||
|
running: true,
|
||||||
|
managedBy: "systemd",
|
||||||
|
blocker: null,
|
||||||
|
job: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const job: NineRouterUpdateJob = {
|
||||||
|
status: "running",
|
||||||
|
startedAt: "2026-10-26T00:00:00Z",
|
||||||
|
finishedAt: null,
|
||||||
|
from: "0.5.104",
|
||||||
|
to: null,
|
||||||
|
managedBy: "systemd",
|
||||||
|
install: "direct",
|
||||||
|
steps: [
|
||||||
|
{ id: "shutdown", status: "done", message: null },
|
||||||
|
{ id: "update", status: "running", message: null },
|
||||||
|
{ id: "relaunch", status: "pending", message: null },
|
||||||
|
],
|
||||||
|
log: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
statusMock.mockReset();
|
||||||
|
startMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NineRouterUpdate", () => {
|
||||||
|
it("renders nothing when 9router is not installed", async () => {
|
||||||
|
statusMock.mockResolvedValue({ ...base, installed: false });
|
||||||
|
const { container } = render(<NineRouterUpdate />);
|
||||||
|
await waitFor(() => expect(statusMock).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens with both versions and starts the three-step job", async () => {
|
||||||
|
statusMock.mockResolvedValue(base);
|
||||||
|
startMock.mockResolvedValue(job);
|
||||||
|
render(<NineRouterUpdate />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||||
|
expect(statusMock).toHaveBeenLastCalledWith(true);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(dialog).toHaveTextContent("0.5.104");
|
||||||
|
expect(dialog).toHaveTextContent("0.5.109");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
|
||||||
|
expect(startMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the blocker with its manual command and keeps the start disabled", async () => {
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
blocker: {
|
||||||
|
code: "ENOPERM",
|
||||||
|
message: "/usr/local is not writable by this account and sudo needs a password",
|
||||||
|
command: "sudo npm install -g 9router@latest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<NineRouterUpdate />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||||
|
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
|
||||||
|
* hidden in local mode and with no usable account; one select per tool that
|
||||||
|
* starts on the user's choice (or Automatic) and saves a pick through
|
||||||
|
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import "../../../i18n";
|
||||||
|
import { PoolAccountSettings } from "../PoolAccountSettings";
|
||||||
|
|
||||||
|
const perms = vi.hoisted(() => ({ local: false }));
|
||||||
|
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||||
|
|
||||||
|
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const account = (name: string) => ({
|
||||||
|
name,
|
||||||
|
kind: "login",
|
||||||
|
email: `${name}@x.io`,
|
||||||
|
expired: false,
|
||||||
|
quotaPercent: 20,
|
||||||
|
full: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
render(<PoolAccountSettings />);
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
perms.local = false;
|
||||||
|
m.poolAccounts.mockReset();
|
||||||
|
m.choosePoolAccount.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PoolAccountSettings", () => {
|
||||||
|
it("renders nothing in local mode or with no account to choose", async () => {
|
||||||
|
perms.local = true;
|
||||||
|
await show();
|
||||||
|
expect(m.poolAccounts).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole("combobox")).toBeNull();
|
||||||
|
perms.local = false;
|
||||||
|
m.poolAccounts.mockResolvedValue({ tools: [] });
|
||||||
|
await show();
|
||||||
|
expect(screen.queryByRole("combobox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts on the user's choice and saves a new pick", async () => {
|
||||||
|
m.poolAccounts.mockResolvedValue({
|
||||||
|
tools: [
|
||||||
|
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
|
||||||
|
await show();
|
||||||
|
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("two");
|
||||||
|
await userEvent.selectOptions(select, "one");
|
||||||
|
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||||
|
await userEvent.selectOptions(select, "");
|
||||||
|
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is locked on an admin's pin", async () => {
|
||||||
|
m.poolAccounts.mockResolvedValue({
|
||||||
|
tools: [
|
||||||
|
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await show();
|
||||||
|
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||||
|
expect(select.disabled).toBe(true);
|
||||||
|
expect(select.value).toBe("one");
|
||||||
|
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for ToolProfilesSettings — Settings → AI tools → Profiles: one
|
||||||
|
* tab per AI tool. Claude's tab asks for EVERY saved account's quota once (a
|
||||||
|
* failing account costs the others nothing), and its right-click delete asks
|
||||||
|
* first and never offers to delete the only copy of the live account; Codex's
|
||||||
|
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
|
||||||
|
* switched; and in server mode a profile links into the account pool.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import "../../../i18n";
|
||||||
|
import { settleConfirm } from "../../../lib/confirm";
|
||||||
|
import { ToolProfilesSettings } from "../ToolProfilesSettings";
|
||||||
|
|
||||||
|
const perms = vi.hoisted(() => ({ isAdmin: true, local: true, seesAll: true }));
|
||||||
|
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||||
|
vi.mock("../../../hooks/useAiTools", () => ({
|
||||||
|
useAiTools: () => ({
|
||||||
|
byId: (id: string) => ({ id, installed: true, enabled: true }),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const m = vi.hoisted(() => {
|
||||||
|
const window = (utilization: number) => ({ utilization, resetsAt: "2026-09-18T08:00:00Z" });
|
||||||
|
return {
|
||||||
|
window,
|
||||||
|
cc: {
|
||||||
|
profiles: vi.fn(),
|
||||||
|
profileUsage: vi.fn(),
|
||||||
|
deleteProfile: vi.fn(),
|
||||||
|
activateProfile: vi.fn(),
|
||||||
|
saveProfile: vi.fn(),
|
||||||
|
renameProfile: vi.fn(),
|
||||||
|
},
|
||||||
|
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
||||||
|
pool: { list: vi.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
ccConfig: m.cc,
|
||||||
|
toolProfiles: m.tp,
|
||||||
|
accountPool: m.pool,
|
||||||
|
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToolProfilesSettings />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
perms.local = true;
|
||||||
|
m.cc.profiles.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||||
|
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||||
|
],
|
||||||
|
active: { accountUuid: "u1", email: "a@b.c" },
|
||||||
|
});
|
||||||
|
m.cc.profileUsage.mockImplementation(async (name: string) => {
|
||||||
|
if (name === "spare") throw new Error("this profile's saved login has been superseded");
|
||||||
|
return { name, refreshed: false, fiveHour: m.window(8), sevenDay: m.window(70) };
|
||||||
|
});
|
||||||
|
m.cc.deleteProfile.mockResolvedValue({ ok: true, name: "spare" });
|
||||||
|
m.tp.list.mockResolvedValue({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
tool: "codex",
|
||||||
|
supported: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "a",
|
||||||
|
label: "Alice",
|
||||||
|
active: true,
|
||||||
|
kind: "chatgpt",
|
||||||
|
email: "a@x",
|
||||||
|
detail: "plus",
|
||||||
|
},
|
||||||
|
{ name: "b", label: "Bob", active: false, kind: "apikey", email: null, detail: "…1234" },
|
||||||
|
],
|
||||||
|
active: { kind: "chatgpt", email: "a@x", detail: "plus" },
|
||||||
|
},
|
||||||
|
{ tool: "omp", supported: true, items: [], active: null },
|
||||||
|
{ tool: "antigravity", supported: false, reason: "keyring", items: [], active: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
||||||
|
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ToolProfilesSettings — Claude", () => {
|
||||||
|
it("asks for every saved account's quota once, a failure costing the others nothing", async () => {
|
||||||
|
await show();
|
||||||
|
await settle();
|
||||||
|
expect(m.cc.profileUsage).toHaveBeenCalledWith("work");
|
||||||
|
expect(m.cc.profileUsage).toHaveBeenCalledWith("spare");
|
||||||
|
expect(m.cc.profileUsage.mock.calls.filter((c) => c[0] === "work")).toHaveLength(1);
|
||||||
|
expect(await screen.findByText("8%")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes from the right-click menu only after confirming", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await show();
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||||
|
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||||
|
await settle();
|
||||||
|
expect(m.cc.deleteProfile).not.toHaveBeenCalled();
|
||||||
|
await act(async () => settleConfirm(true));
|
||||||
|
await settle();
|
||||||
|
expect(m.cc.deleteProfile).toHaveBeenCalledWith("spare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no delete for the only saved copy of the live account", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await show();
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||||
|
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ToolProfilesSettings — other tools", () => {
|
||||||
|
it("switches a Codex login through /api/tool-profiles", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await show();
|
||||||
|
await user.click(screen.getByRole("tab", { name: /codex/i }));
|
||||||
|
const bob = screen.getByTestId("profile-row-codex-b");
|
||||||
|
expect(bob).toHaveTextContent("…1234");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Switch to" }));
|
||||||
|
await settle();
|
||||||
|
expect(m.tp.activate).toHaveBeenCalledWith("codex", "b");
|
||||||
|
expect(m.tp.list).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says why Antigravity cannot be switched", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await show();
|
||||||
|
await user.click(screen.getByRole("tab", { name: /antigravity/i }));
|
||||||
|
expect(screen.getByText(/system keyring/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ToolProfilesSettings — account pool", () => {
|
||||||
|
it("is not shown in local mode", async () => {
|
||||||
|
await show();
|
||||||
|
expect(m.pool.list).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText(/Move to pool/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("in server mode shows the pool's size and links a profile into it", async () => {
|
||||||
|
perms.local = false;
|
||||||
|
await show();
|
||||||
|
expect(screen.getByText(/holds 1 account/)).toBeInTheDocument();
|
||||||
|
const [link] = screen.getAllByRole("link", { name: /Move to pool/ });
|
||||||
|
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,38 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* @file AccountPoolCard.tsx
|
* @file AccountPoolCard.tsx
|
||||||
* @description The Users page's account pool card (`GET /api/accounts`): the
|
* @description The Users page's account pool card (`GET /api/accounts`).
|
||||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
* Claude Code, Codex and Cursor accounts live on the POOLS board
|
||||||
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
|
* (`PoolGroups`): one column per pool with its accounts of every tool — each
|
||||||
* per user. An admin adds one — for Claude a login MOVED in from a saved
|
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
|
||||||
* switch-user profile (`GET /api/accounts/profiles`), a pasted
|
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
|
||||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
* tools with no pool get a tab each below it: Oh My Pi can share the host's
|
||||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
* login (the same switch as Settings → AI tools,
|
||||||
* password again; an analyst only reads. The secret only goes IN: the server
|
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
|
||||||
* never sends it back. An account's Edit (pencil) picks the users who run on
|
* says that its users sign in themselves. An admin adds an account from the
|
||||||
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
|
* header's Add account, picking its tool — for Claude a
|
||||||
* a Claude login goes into their home at once — no restart.
|
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||||
|
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||||
|
* `claude setup-token` token; for Codex a saved Codex profile
|
||||||
|
* (`?tool=codex`), an OpenAI API key or a pasted `auth.json`; for Cursor a
|
||||||
|
* Cursor API key — or removes one, each time with their own password again;
|
||||||
|
* an analyst only reads. The secret only goes IN: the server never sends it
|
||||||
|
* back. An account's Edit (pencil) picks the users who run on it
|
||||||
|
* (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role
|
||||||
|
* or "no group"): their sessions skip the balancer, and a
|
||||||
|
* Claude login goes into their home at once — no restart. A profile's
|
||||||
|
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||||
|
* which opens Add on that profile; the header links back to the profiles.
|
||||||
* @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 KeyboardEvent, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type AccountPin,
|
type AccountPin,
|
||||||
type AccountPool,
|
type AccountPool,
|
||||||
|
type OmpSharingState,
|
||||||
type PoolAccountInput,
|
type PoolAccountInput,
|
||||||
type PoolProfile,
|
type PoolProfile,
|
||||||
type PoolTool,
|
type PoolTool,
|
||||||
type ServerUserUsage,
|
type ServerUserUsage,
|
||||||
|
type CcUsageWindow,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { Checkbox } from "../Checkbox";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||||
|
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
|
||||||
|
import { MemberPicker } from "./MemberPicker";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
tool: PoolTool;
|
tool: PoolTool;
|
||||||
@@ -49,19 +67,78 @@ type Row = {
|
|||||||
tokens7d: number;
|
tokens7d: number;
|
||||||
quotaPercent: number | null;
|
quotaPercent: number | null;
|
||||||
full: boolean;
|
full: boolean;
|
||||||
|
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||||
|
windows: QuotaWindow[];
|
||||||
|
/** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */
|
||||||
|
plan?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function rowsOf(pool: AccountPool): Row[] {
|
/** One quota window of an account: % used and when it rolls over. */
|
||||||
return [
|
export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||||
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
|
|
||||||
...pool.codex.map((a) => ({
|
/**
|
||||||
...a,
|
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||||
tool: "codex" as const,
|
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||||
expired: false,
|
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||||
email: null,
|
*/
|
||||||
refreshError: null,
|
export function windowsOf(
|
||||||
})),
|
a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
|
||||||
];
|
): QuotaWindow[] {
|
||||||
|
const q = a.quota as
|
||||||
|
| {
|
||||||
|
fiveHour?: CcUsageWindow | null;
|
||||||
|
sevenDay?: CcUsageWindow | null;
|
||||||
|
windows?: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
const fromClaude = (w: CcUsageWindow | null | undefined) =>
|
||||||
|
w ? { used: w.utilization, resetsAt: w.resetsAt } : null;
|
||||||
|
const fromCodex = (label: string) => {
|
||||||
|
const w = q?.windows?.find((x) => x.label === label);
|
||||||
|
return w ? { used: w.usedPercent, resetsAt: w.resetsAt } : null;
|
||||||
|
};
|
||||||
|
return (["5h", "wk"] as const).map((key) => {
|
||||||
|
const w = (key === "5h" ? fromClaude(q?.fiveHour) : fromClaude(q?.sevenDay)) ?? fromCodex(key);
|
||||||
|
const passed = w?.resetsAt != null && Date.parse(w.resetsAt) <= Date.now();
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
used: passed ? 0 : (w?.used ?? null),
|
||||||
|
resetsAt: passed ? null : (w?.resetsAt ?? null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2h 10m" / "3d 4h" until `iso`, or null once it has passed. */
|
||||||
|
function untilText(iso: string | null): string | null {
|
||||||
|
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||||
|
if (!(ms > 0)) return null;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const d = Math.floor(m / 1440);
|
||||||
|
const h = Math.floor((m % 1440) / 60);
|
||||||
|
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||||
|
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||||
|
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
|
||||||
|
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
|
||||||
|
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||||
|
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||||
|
|
||||||
|
const isPoolTool = (id: string): id is PoolTool => (POOL_TOOLS as string[]).includes(id);
|
||||||
|
|
||||||
|
function rowsOf(pool: AccountPool, tool: PoolTool): Row[] {
|
||||||
|
if (tool === "claude") return pool.accounts.map((a) => ({ ...a, tool, windows: windowsOf(a) }));
|
||||||
|
const list = tool === "codex" ? pool.codex : (pool.cursor ?? []);
|
||||||
|
return list.map((a) => ({
|
||||||
|
...a,
|
||||||
|
tool,
|
||||||
|
expired: false,
|
||||||
|
email: null,
|
||||||
|
refreshError: null,
|
||||||
|
windows: tool === "codex" ? windowsOf(a as AccountPool["codex"][number]) : [],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||||
@@ -75,9 +152,30 @@ function errorText(t: TFunction<"users">, e: unknown): string {
|
|||||||
|
|
||||||
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
|
const aiTools = useAiTools();
|
||||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||||
|
const [omp, setOmp] = useState<OmpSharingState | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [params, setParams] = useSearchParams();
|
||||||
|
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
|
||||||
|
const preset = (() => {
|
||||||
|
const tool = params.get("poolAdd");
|
||||||
|
const profile = params.get("poolProfile");
|
||||||
|
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||||
|
})();
|
||||||
|
const [tab, setTab] = useState<string>("omp");
|
||||||
|
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||||
|
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||||
|
isAdmin && preset ? preset : null
|
||||||
|
);
|
||||||
|
const closeAdd = () => {
|
||||||
|
setAdding(null);
|
||||||
|
if (!preset) return;
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.delete("poolAdd");
|
||||||
|
next.delete("poolProfile");
|
||||||
|
setParams(next, { replace: true });
|
||||||
|
};
|
||||||
const [removing, setRemoving] = useState<Row | null>(null);
|
const [removing, setRemoving] = useState<Row | null>(null);
|
||||||
const [editing, setEditing] = useState<Row | null>(null);
|
const [editing, setEditing] = useState<Row | null>(null);
|
||||||
|
|
||||||
@@ -96,19 +194,60 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
|
// Oh My Pi's tab: the host's login shared through omp's own gateway (S3).
|
||||||
|
api.aiTools
|
||||||
|
.ompSharing()
|
||||||
|
.then(setOmp)
|
||||||
|
.catch(() => setOmp(null));
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const rows = pool ? rowsOf(pool) : [];
|
// Every other INSTALLED tool gets a tab too, so "no pool" is said, not implied.
|
||||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
const tabs = [
|
||||||
|
...FIXED_TABS,
|
||||||
|
...aiTools.tools
|
||||||
|
.filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id))
|
||||||
|
.map((x) => ({ id: x.id, label: x.label })),
|
||||||
|
];
|
||||||
|
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||||
|
const badge = (id: string): string | null =>
|
||||||
|
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||||
|
const rowOf = (tool: PoolTool, name: string) =>
|
||||||
|
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card" data-testid="account-pool">
|
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-[14rem]">
|
||||||
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
||||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||||
|
{t("pool.profilesLink")}
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<label className="relative">
|
||||||
|
<span className="btn-primary pointer-events-none text-xs">
|
||||||
|
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="absolute inset-0 cursor-pointer opacity-0"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
|
||||||
|
}}
|
||||||
|
aria-label={t("pool.addAccount")}
|
||||||
|
data-testid="pool-add-account"
|
||||||
|
>
|
||||||
|
<option value="">{t("pool.addAccount")}</option>
|
||||||
|
{POOL_TOOL_LABELS.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
@@ -120,125 +259,88 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{isAdmin && (
|
|
||||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
|
||||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{pool && rows.length === 0 && (
|
{pool && (
|
||||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
|
<PoolGroups
|
||||||
)}
|
pool={pool}
|
||||||
{rows.length > 0 && (
|
isAdmin={isAdmin}
|
||||||
<div className="overflow-x-auto">
|
onChanged={() => void load()}
|
||||||
<table className="w-full min-w-[640px]">
|
renderAccount={(tool, name) => {
|
||||||
<thead>
|
const r = rowOf(tool, name);
|
||||||
<tr className="border-b border-border text-left">
|
return r ? (
|
||||||
<th className={th}>{t("pool.account")}</th>
|
<AccountCard
|
||||||
<th className={th}>{t("pool.tool")}</th>
|
row={r}
|
||||||
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
|
pool={pool}
|
||||||
<th className={`${th} text-right`}>{t("pool.quota")}</th>
|
isAdmin={isAdmin}
|
||||||
<th className={`${th} text-right`}>{t("pool.spend")}</th>
|
onEdit={() => setEditing(r)}
|
||||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
onRemove={() => setRemoving(r)}
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
) : null;
|
||||||
<tbody className="divide-y divide-border">
|
}}
|
||||||
{rows.map((r) => (
|
/>
|
||||||
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
|
|
||||||
<td className="px-5 py-2.5">
|
|
||||||
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
|
|
||||||
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
|
|
||||||
<div className="text-[11px] text-fg-muted">
|
|
||||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
|
||||||
</div>
|
|
||||||
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
|
|
||||||
<div className="text-[11px] text-fg-secondary">
|
|
||||||
{t("pool.pinned", {
|
|
||||||
users: pinsOf(pool, r.tool)
|
|
||||||
.filter((p) => p.account === r.name)
|
|
||||||
.map((p) => p.username)
|
|
||||||
.join(", "),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
|
||||||
{t(`pool.kind.${r.kind}`)}
|
|
||||||
{r.expired && (
|
|
||||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
|
||||||
{t("pool.expired")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{r.refreshError && !r.expired && (
|
|
||||||
<span
|
|
||||||
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
|
||||||
title={r.refreshError}
|
|
||||||
>
|
|
||||||
{t("pool.refreshFailed")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{r.full && (
|
|
||||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
|
||||||
{t("pool.full")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
|
||||||
{r.livePanes}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
|
||||||
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-right tabular-nums">
|
|
||||||
<div className="text-sm text-fg-primary">
|
|
||||||
{fmtCost(r.spend5h)}
|
|
||||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
|
||||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-fg-muted">
|
|
||||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{isAdmin && (
|
|
||||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditing(r)}
|
|
||||||
title={t("pool.edit", { name: r.name })}
|
|
||||||
aria-label={t("pool.edit", { name: r.name })}
|
|
||||||
className="btn-icon"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRemoving(r)}
|
|
||||||
title={t("pool.remove", { name: r.name })}
|
|
||||||
aria-label={t("pool.remove", { name: r.name })}
|
|
||||||
className="btn-icon"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t("pool.otherTools")}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t("pool.otherTools")}
|
||||||
|
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
|
||||||
|
>
|
||||||
|
{tabs.map((x) => {
|
||||||
|
const selected = x.id === current.id;
|
||||||
|
const count = badge(x.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={x.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`pool-tab-${x.id}`}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-controls="pool-panel"
|
||||||
|
onClick={() => setTab(x.id)}
|
||||||
|
className={`-mb-px inline-flex shrink-0 items-center gap-1.5 border-b-2 px-3 pb-2.5 pt-1 text-sm transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-accent text-fg-primary font-medium"
|
||||||
|
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AiToolIcon tool={x} className="h-4 w-4" />
|
||||||
|
{x.label}
|
||||||
|
{count !== null && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-1.5 text-[10px] tabular-nums ${
|
||||||
|
selected ? "bg-accent/15 text-accent" : "bg-surface-3 text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||||
|
{current.id === "omp" ? (
|
||||||
|
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||||
|
) : (
|
||||||
|
<NoPoolPanel id={current.id} label={current.label} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{adding && (
|
{adding && (
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
onClose={() => setAdding(false)}
|
tool={adding.tool}
|
||||||
|
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||||
|
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||||
|
onClose={closeAdd}
|
||||||
onDone={(name) => {
|
onDone={(name) => {
|
||||||
toast.success(t("pool.done.added", { name }));
|
toast.success(t("pool.done.added", { name }));
|
||||||
setAdding(false);
|
closeAdd();
|
||||||
void load();
|
void load();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -278,6 +380,251 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
|
||||||
|
function AccountCard({
|
||||||
|
row: r,
|
||||||
|
pool,
|
||||||
|
isAdmin,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
row: Row;
|
||||||
|
pool: AccountPool;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const metered = METERED.has(r.tool);
|
||||||
|
const users = pinsOf(pool, r.tool)
|
||||||
|
.filter((p) => p.account === r.name)
|
||||||
|
.map((p) => p.username);
|
||||||
|
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
|
||||||
|
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||||
|
}`}
|
||||||
|
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||||
|
data-testid="pool-account"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
|
||||||
|
{r.plan && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
|
||||||
|
data-testid="pool-account-plan"
|
||||||
|
>
|
||||||
|
{r.plan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
|
||||||
|
{t(`pool.kindShort.${r.kind}`)}
|
||||||
|
</span>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
title={t("pool.edit", { name: r.name })}
|
||||||
|
aria-label={t("pool.edit", { name: r.name })}
|
||||||
|
className="btn-icon p-1"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
title={t("pool.remove", { name: r.name })}
|
||||||
|
aria-label={t("pool.remove", { name: r.name })}
|
||||||
|
className="btn-icon p-1 hover:text-status-danger"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(r.expired || r.refreshError || r.full) && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{r.expired && (
|
||||||
|
<span className="badge py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||||
|
{t("pool.expired")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.refreshError && !r.expired && (
|
||||||
|
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
|
||||||
|
{t("pool.refreshFailed")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metered && (
|
||||||
|
<div className="space-y-1" data-testid="pool-quota">
|
||||||
|
{r.windows.map((w) => (
|
||||||
|
<QuotaBar key={w.key} window={w} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
|
||||||
|
<span className="inline-flex items-center gap-1 text-fg-secondary">
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{t("pool.liveCount", { count: r.livePanes })}
|
||||||
|
</span>
|
||||||
|
{metered && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>
|
||||||
|
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||||
|
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||||
|
</span>
|
||||||
|
<span className="basis-full">
|
||||||
|
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{users.length > 0 && (
|
||||||
|
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||||
|
<UserRound className="h-3 w-3" aria-hidden />
|
||||||
|
{t("pool.pinned", { users: users.join(", ") })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||||
|
* 0% = used up), with when it resets.
|
||||||
|
*/
|
||||||
|
export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
|
||||||
|
const resets = untilText(w.resetsAt);
|
||||||
|
return (
|
||||||
|
<div className="text-[11px]">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||||
|
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||||
|
<span
|
||||||
|
className={`block h-full transition-[width] duration-500 ${
|
||||||
|
left == null
|
||||||
|
? ""
|
||||||
|
: left === 0
|
||||||
|
? "bg-status-danger"
|
||||||
|
: left <= 10
|
||||||
|
? "bg-status-warning"
|
||||||
|
: "bg-accent-solid"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${left ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums text-fg-secondary">
|
||||||
|
{left == null ? "—" : `${left}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{resets && (
|
||||||
|
<div
|
||||||
|
className="pl-[3.875rem] text-[10px] text-fg-muted"
|
||||||
|
title={formatDateTime(w.resetsAt!)}
|
||||||
|
>
|
||||||
|
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||||
|
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||||
|
* same admin switch as Settings → AI tools. Only with per-user accounts (S3).
|
||||||
|
*/
|
||||||
|
function OmpPanel({
|
||||||
|
state,
|
||||||
|
isAdmin,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
state: OmpSharingState | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onChange: (s: OmpSharingState) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const ts = useTranslation("settings").t;
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!state?.available) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-5 py-6">
|
||||||
|
<Share2 className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
<p className="text-sm text-fg-muted leading-relaxed">{t("pool.omp.unavailable")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const error = state.broker.error || state.gateway.error;
|
||||||
|
const status = !state.enabled
|
||||||
|
? t("pool.omp.off")
|
||||||
|
: !state.installed
|
||||||
|
? ts("ompSharing.notInstalled")
|
||||||
|
: state.broker.running && state.gateway.running
|
||||||
|
? ts("ompSharing.running")
|
||||||
|
: error
|
||||||
|
? ts("ompSharing.failed", { error })
|
||||||
|
: ts("ompSharing.starting");
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-3 space-y-2">
|
||||||
|
<p className="text-xs text-fg-secondary leading-relaxed">{t("pool.omp.about")}</p>
|
||||||
|
{isAdmin ? (
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={ts("ompSharing.switch")}
|
||||||
|
description={ts("ompSharing.hint")}
|
||||||
|
checked={state.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={async (on) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
onChange(await api.aiTools.setOmpSharing(on));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<p data-testid="omp-pool-status" className="pb-2 text-xs text-fg-muted">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any other tool: no pool — say so, and why for the ones we know. */
|
||||||
|
function NoPoolPanel({ id, label }: { id: string; label: string }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-5 py-6">
|
||||||
|
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-fg-secondary">{t("pool.none.title", { tool: label })}</p>
|
||||||
|
<p className="text-xs text-fg-muted leading-relaxed">
|
||||||
|
{t(`pool.none.reason.${id}`, {
|
||||||
|
defaultValue: t("pool.none.reason.default", { tool: label }),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One account's Edit: tick the users whose sessions run on it. A user already
|
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||||
* on another account of the tool moves here when ticked; unticking one sends
|
* on another account of the tool moves here when ticked; unticking one sends
|
||||||
@@ -312,13 +659,8 @@ function EditAccountDialog({
|
|||||||
|
|
||||||
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||||
const ready = changed && current.length > 0;
|
const ready = changed && current.length > 0;
|
||||||
const toggle = (id: number, on: boolean) =>
|
const groupOf = (userId: number) =>
|
||||||
setChosen((prev) => {
|
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||||
const next = new Set(prev);
|
|
||||||
if (on) next.add(id);
|
|
||||||
else next.delete(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!ready || busy) return;
|
if (!ready || busy) return;
|
||||||
@@ -357,38 +699,28 @@ function EditAccountDialog({
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||||
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
<div className="mt-1">
|
||||||
{users === null && (
|
<MemberPicker
|
||||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
users={users}
|
||||||
)}
|
chosen={chosen}
|
||||||
{users?.length === 0 && (
|
onChange={setChosen}
|
||||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
groupOf={groupOf}
|
||||||
)}
|
grouped={(pool.groups ?? []).length > 0}
|
||||||
{users?.map((u) => {
|
note={(u) => {
|
||||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||||
return (
|
const group = groupOf(u.id);
|
||||||
<div key={u.id} className="px-3 py-2">
|
return (
|
||||||
<Checkbox
|
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||||
checked={chosen.has(u.id)}
|
{[
|
||||||
onChange={(on) => toggle(u.id, on)}
|
group && t("pool.groups.inGroup", { group }),
|
||||||
label={
|
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
|
||||||
<span>
|
]
|
||||||
{u.username}
|
.filter(Boolean)
|
||||||
{u.disabled && (
|
.join(" · ")}
|
||||||
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
</span>
|
||||||
)}
|
);
|
||||||
{elsewhere && (
|
}}
|
||||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
/>
|
||||||
{t("pool.onAccount", { account: elsewhere.account })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
labelClassName="text-sm text-fg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||||
@@ -412,19 +744,33 @@ function EditAccountDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ClaudeSource = "profile" | "credentials" | "token";
|
type ClaudeSource = "profile" | "credentials" | "token";
|
||||||
|
/** What each tool's pool account can come from (server/routes/accounts.js). */
|
||||||
|
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
|
||||||
|
claude: ["profile", "credentials", "token"],
|
||||||
|
codex: ["profile", "token"],
|
||||||
|
cursor: ["token"],
|
||||||
|
};
|
||||||
|
|
||||||
function AddAccountDialog({
|
function AddAccountDialog({
|
||||||
|
tool,
|
||||||
|
label,
|
||||||
|
preset,
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
|
/** The open tab's tool: the account is added to its pool. */
|
||||||
|
tool: PoolTool;
|
||||||
|
label: string;
|
||||||
|
/** A saved profile to move in, from its "Move to pool" link. */
|
||||||
|
preset?: { tool: PoolTool; profile: string } | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: (name: string) => void;
|
onDone: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
const [tool, setTool] = useState<PoolTool>("claude");
|
const sources = SOURCES[tool];
|
||||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
const [source, setSource] = useState<ClaudeSource>(sources[0] ?? "token");
|
||||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||||
const [profile, setProfile] = useState("");
|
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [current, setCurrent] = useState("");
|
const [current, setCurrent] = useState("");
|
||||||
@@ -432,13 +778,14 @@ function AddAccountDialog({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!sources.includes("profile")) return;
|
||||||
api.accountPool
|
api.accountPool
|
||||||
.profiles()
|
.profiles(tool)
|
||||||
.then((r) => setProfiles(r.profiles))
|
.then((r) => setProfiles(r.profiles))
|
||||||
.catch(() => setProfiles([]));
|
.catch(() => setProfiles([]));
|
||||||
}, []);
|
}, [tool, sources]);
|
||||||
|
|
||||||
const fromProfile = tool === "claude" && source === "profile";
|
const fromProfile = source === "profile";
|
||||||
// A profile already names itself: its name is the default.
|
// A profile already names itself: its name is the default.
|
||||||
const ready =
|
const ready =
|
||||||
current.length > 0 &&
|
current.length > 0 &&
|
||||||
@@ -449,13 +796,11 @@ function AddAccountDialog({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const body: PoolAccountInput =
|
const body: PoolAccountInput =
|
||||||
tool === "codex"
|
source === "profile"
|
||||||
? { tool, name: name.trim(), token: secret.trim() }
|
? { tool, name: name.trim() || profile, profile }
|
||||||
: source === "profile"
|
: tool === "claude" && source === "credentials"
|
||||||
? { tool, name: name.trim() || profile, profile }
|
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||||
: source === "credentials"
|
: { tool, name: name.trim(), token: secret.trim() };
|
||||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
|
||||||
: { tool, name: name.trim(), token: secret.trim() };
|
|
||||||
try {
|
try {
|
||||||
const r = await api.accountPool.add(body, current);
|
const r = await api.accountPool.add(body, current);
|
||||||
onDone(r.account.name);
|
onDone(r.account.name);
|
||||||
@@ -472,12 +817,13 @@ function AddAccountDialog({
|
|||||||
void submit();
|
void submit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const secretKind = tool === "codex" ? "codex" : source;
|
// Codex's and Cursor's one non-profile source is their own kind of key.
|
||||||
|
const secretKind = tool === "claude" ? source : tool;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open
|
open
|
||||||
title={t("pool.add")}
|
title={t("pool.addFor", { tool: label })}
|
||||||
confirmLabel={t("pool.add")}
|
confirmLabel={t("pool.add")}
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
destructive={false}
|
destructive={false}
|
||||||
@@ -487,28 +833,22 @@ function AddAccountDialog({
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
>
|
>
|
||||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||||
<Field label={t("pool.tool")}>
|
{sources.length > 1 && (
|
||||||
<select
|
<Field
|
||||||
className="input w-full text-sm"
|
label={t("pool.source")}
|
||||||
value={tool}
|
hint={t(`pool.sourceHint.${tool !== "claude" && source === "token" ? tool : source}`)}
|
||||||
onChange={(e) => setTool(e.target.value as PoolTool)}
|
|
||||||
aria-label={t("pool.tool")}
|
|
||||||
>
|
>
|
||||||
<option value="claude">Claude Code</option>
|
|
||||||
<option value="codex">Codex</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
{tool === "claude" && (
|
|
||||||
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
|
|
||||||
<select
|
<select
|
||||||
className="input w-full text-sm"
|
className="input w-full text-sm"
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||||
aria-label={t("pool.source")}
|
aria-label={t("pool.source")}
|
||||||
>
|
>
|
||||||
{(["profile", "credentials", "token"] as const).map((s) => (
|
{sources.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
{t(`pool.sources.${s}`)}
|
{tool !== "claude" && s === "token"
|
||||||
|
? t(`pool.secret.${tool}`)
|
||||||
|
: t(`pool.sources.${s}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -553,7 +893,7 @@ function AddAccountDialog({
|
|||||||
</Field>
|
</Field>
|
||||||
{!fromProfile && (
|
{!fromProfile && (
|
||||||
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
||||||
{secretKind === "token" ? (
|
{secretKind === "token" || secretKind === "cursor" ? (
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className="input w-full font-mono text-sm"
|
className="input w-full font-mono text-sm"
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* @file MemberPicker.tsx
|
||||||
|
* @description The pool dialogs' member list (an account's Edit, a group's
|
||||||
|
* Edit): filter chips by role (Admin / Developer / Analyst, each with its
|
||||||
|
* count) plus "No group" for the users in no pool group, a search box, a
|
||||||
|
* tick-all for what is shown, and one checkbox per user with their role and
|
||||||
|
* where they are now. Pure UI: the caller owns the ticked set.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import type { AuthUser, ServerUserUsage } from "../../lib/api";
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
|
|
||||||
|
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
|
||||||
|
/** A role, every user, or the users in no pool group. */
|
||||||
|
export type MemberFilter = "all" | "nogroup" | AuthUser["role"];
|
||||||
|
|
||||||
|
/** The chips' filter applied to one user. */
|
||||||
|
export function matchesFilter(
|
||||||
|
u: Pick<ServerUserUsage, "id" | "role">,
|
||||||
|
filter: MemberFilter,
|
||||||
|
groupOf: (userId: number) => string | null
|
||||||
|
): boolean {
|
||||||
|
if (filter === "all") return true;
|
||||||
|
if (filter === "nogroup") return groupOf(u.id) === null;
|
||||||
|
return u.role === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of filter chips with counts; `nogroup` only when groups exist. */
|
||||||
|
export function MemberFilterChips({
|
||||||
|
users,
|
||||||
|
filter,
|
||||||
|
onFilter,
|
||||||
|
groupOf,
|
||||||
|
grouped,
|
||||||
|
}: {
|
||||||
|
users: Pick<ServerUserUsage, "id" | "role">[];
|
||||||
|
filter: MemberFilter;
|
||||||
|
onFilter: (f: MemberFilter) => void;
|
||||||
|
groupOf: (userId: number) => string | null;
|
||||||
|
grouped: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const chips: { id: MemberFilter; label: string }[] = [
|
||||||
|
{ id: "all", label: t("pool.members.all") },
|
||||||
|
...ROLES.map((r) => ({ id: r as MemberFilter, label: t(`roles.${r}`) })),
|
||||||
|
...(grouped ? [{ id: "nogroup" as MemberFilter, label: t("pool.members.noGroup") }] : []),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={t("pool.members.filter")} className="flex flex-wrap gap-1">
|
||||||
|
{chips.map((c) => {
|
||||||
|
const n = users.filter((u) => matchesFilter(u, c.id, groupOf)).length;
|
||||||
|
if (n === 0 && c.id !== "all" && c.id !== filter) return null;
|
||||||
|
const on = filter === c.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => onFilter(c.id)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
|
||||||
|
on
|
||||||
|
? "border-accent/50 bg-accent/15 text-accent"
|
||||||
|
: "border-border text-fg-muted hover:text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
<span className="tabular-nums opacity-70">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberPicker({
|
||||||
|
users,
|
||||||
|
chosen,
|
||||||
|
onChange,
|
||||||
|
groupOf,
|
||||||
|
grouped,
|
||||||
|
note,
|
||||||
|
}: {
|
||||||
|
users: ServerUserUsage[] | null;
|
||||||
|
chosen: Set<number>;
|
||||||
|
onChange: (next: Set<number>) => void;
|
||||||
|
/** The pool group a user is in now, by name, or null. */
|
||||||
|
groupOf: (userId: number) => string | null;
|
||||||
|
/** Whether any pool group exists (shows the "No group" chip). */
|
||||||
|
grouped: boolean;
|
||||||
|
/** Extra text after a user's name (where they run now). */
|
||||||
|
note?: (u: ServerUserUsage) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [filter, setFilter] = useState<MemberFilter>("all");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const shown = (users ?? []).filter(
|
||||||
|
(u) =>
|
||||||
|
matchesFilter(u, filter, groupOf) &&
|
||||||
|
(!q ||
|
||||||
|
u.username.toLowerCase().includes(q) ||
|
||||||
|
(u.displayName ?? "").toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
const allShown = shown.length > 0 && shown.every((u) => chosen.has(u.id));
|
||||||
|
const setMany = (ids: number[], on: boolean) => {
|
||||||
|
const next = new Set(chosen);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" data-testid="member-picker">
|
||||||
|
{users && users.length > 0 && (
|
||||||
|
<>
|
||||||
|
<MemberFilterChips
|
||||||
|
users={users}
|
||||||
|
filter={filter}
|
||||||
|
onFilter={setFilter}
|
||||||
|
groupOf={groupOf}
|
||||||
|
grouped={grouped}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||||
|
<input
|
||||||
|
className="input w-full py-1 pl-7 text-xs"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("pool.members.search")}
|
||||||
|
aria-label={t("pool.members.search")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Checkbox
|
||||||
|
checked={allShown}
|
||||||
|
disabled={shown.length === 0}
|
||||||
|
onChange={(on) =>
|
||||||
|
setMany(
|
||||||
|
shown.map((u) => u.id),
|
||||||
|
on
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={t("pool.members.tickShown", { count: shown.length })}
|
||||||
|
labelClassName="text-[11px] text-fg-secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
|
{users === null && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||||
|
)}
|
||||||
|
{users?.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||||
|
)}
|
||||||
|
{users && users.length > 0 && shown.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.members.noneShown")}</p>
|
||||||
|
)}
|
||||||
|
{shown.map((u) => (
|
||||||
|
<div key={u.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={chosen.has(u.id)}
|
||||||
|
onChange={(on) => setMany([u.id], on)}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{u.username}
|
||||||
|
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||||
|
{note?.(u)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
labelClassName="text-sm text-fg-primary"
|
||||||
|
/>
|
||||||
|
<span className="badge shrink-0 py-0 text-[10px]">{t(`roles.${u.role}`)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* @file MemberTmux.tsx
|
||||||
|
* @description Each member's own tmux server as part of the Administration
|
||||||
|
* page's users table (one Linux user per account only, `GET /api/users/tmux`,
|
||||||
|
* `useMemberTmux`): a `TmuxCell` per row — the version it runs, a badge when
|
||||||
|
* older than the installed one, its sessions, whether an agent of theirs is
|
||||||
|
* working — and `MemberTmuxBar` above the table, so an admin sees who still
|
||||||
|
* runs an old tmux after an upgrade.
|
||||||
|
* An admin restarts one member's server, or every idle outdated one at once
|
||||||
|
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||||
|
* time with their own password again; a restart ends that member's panes,
|
||||||
|
* and the next start runs the installed tmux. An analyst only reads.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { RotateCcw, TerminalSquare } from "lucide-react";
|
||||||
|
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||||
|
import { formatDateTime } from "../../lib/format";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
|
||||||
|
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||||
|
const err = e as Error & { code?: string };
|
||||||
|
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||||
|
? t(`tmux.errors.${err.code}`)
|
||||||
|
: err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||||
|
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||||
|
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||||
|
|
||||||
|
/** `GET /api/users/tmux`; `available` false without one Linux user per account. */
|
||||||
|
export function useMemberTmux(enabled = true) {
|
||||||
|
const [data, setData] = useState<MemberTmux | null>(null);
|
||||||
|
const reload = useCallback(
|
||||||
|
() =>
|
||||||
|
api.users
|
||||||
|
.tmux()
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setData(null)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) void reload();
|
||||||
|
}, [enabled, reload]);
|
||||||
|
return { data, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One member's tmux server as a users-table cell: version, badges, sessions, agents. */
|
||||||
|
export function TmuxCell({ row }: { row?: MemberTmuxRow }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
if (!row?.running) return <span className="text-sm text-fg-muted">—</span>;
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-fg-secondary">
|
||||||
|
<span className="font-mono">{row.stuck ? "?" : (row.version ?? "?")}</span>
|
||||||
|
{row.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||||
|
{row.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||||
|
<div
|
||||||
|
className="text-[11px] text-fg-muted"
|
||||||
|
title={
|
||||||
|
row.startedAt
|
||||||
|
? t("tmux.since", { at: formatDateTime(new Date(row.startedAt).toISOString()) })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("tmux.sessionsCount", { count: row.sessions ?? 0 })} ·{" "}
|
||||||
|
{row.busy > 0 ? t("tmux.working", { count: row.busy }) : t("tmux.idle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above the users table: the installed tmux, the not-yet-installed tmux-member
|
||||||
|
* warning, and (an admin) "Restart N idle members". Nothing without S3.
|
||||||
|
*/
|
||||||
|
export function MemberTmuxBar({
|
||||||
|
data,
|
||||||
|
isAdmin,
|
||||||
|
onRestart,
|
||||||
|
}: {
|
||||||
|
data: MemberTmux | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onRestart: (target: "idle") => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
if (!data?.available) return null;
|
||||||
|
const idle = data.users.filter((u) => u.running && u.outdated && u.busy === 0);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs" data-testid="member-tmux">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-fg-muted">
|
||||||
|
<TerminalSquare className="w-3.5 h-3.5" />
|
||||||
|
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||||
|
</span>
|
||||||
|
{!data.matched && <span className="text-status-warning">{t("tmux.notMatched")}</span>}
|
||||||
|
{isAdmin && idle.length > 0 && (
|
||||||
|
<button type="button" onClick={() => onRestart("idle")} className="btn-ghost text-xs">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" /> {t("tmux.restartIdle", { count: idle.length })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An admin's restart of one member's tmux, or of every idle outdated one. */
|
||||||
|
export function TmuxRestartDialog({
|
||||||
|
target,
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
target: MemberTmuxRow | "idle";
|
||||||
|
data: MemberTmux | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const idle = (data?.users ?? []).filter((u) => u.running && u.outdated && u.busy === 0);
|
||||||
|
return <RestartDialog target={target} idle={idle} onClose={onClose} onDone={onDone} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestartDialog({
|
||||||
|
target,
|
||||||
|
idle,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
target: MemberTmuxRow | "idle";
|
||||||
|
idle: MemberTmuxRow[];
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const one = target === "idle" ? null : target;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!current || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (one) {
|
||||||
|
await api.users.tmuxRestart(one.id, current);
|
||||||
|
onDone(t("tmux.done", { name: one.username }));
|
||||||
|
} else {
|
||||||
|
const r = await api.users.tmuxRestartIdle(current);
|
||||||
|
// A server that would not stop needs root: say which, the rest went.
|
||||||
|
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||||
|
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorText(t, e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||||
|
message={
|
||||||
|
one
|
||||||
|
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||||
|
name: one.username,
|
||||||
|
count: one.sessions ?? 0,
|
||||||
|
})
|
||||||
|
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||||
|
}
|
||||||
|
confirmLabel={t("tmux.restartButton")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!current}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input w-full text-sm"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("yourPassword")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file MemberTmuxCard.tsx
|
|
||||||
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
|
|
||||||
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
|
|
||||||
* beside the installed one, its sessions, whether an agent of theirs is
|
|
||||||
* working — so an admin sees who still runs an old tmux after an upgrade.
|
|
||||||
* An admin restarts one member's server, or every idle outdated one at once
|
|
||||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
|
||||||
* time with their own password again; a restart ends that member's panes,
|
|
||||||
* and the next start runs the installed tmux. An analyst only reads.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
|
|
||||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
|
||||||
import { formatDateTime } from "../../lib/format";
|
|
||||||
import { toast } from "../../lib/toast";
|
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
|
||||||
|
|
||||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
|
||||||
const err = e as Error & { code?: string };
|
|
||||||
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
|
||||||
? t(`tmux.errors.${err.code}`)
|
|
||||||
: err.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
|
||||||
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
|
||||||
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
|
||||||
|
|
||||||
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
|
|
||||||
const { t } = useTranslation("users");
|
|
||||||
const [data, setData] = useState<MemberTmux | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
// A member row = restart that one; "idle" = every idle outdated member.
|
|
||||||
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(
|
|
||||||
() =>
|
|
||||||
api.users
|
|
||||||
.tmux()
|
|
||||||
.then((r) => {
|
|
||||||
setData(r);
|
|
||||||
setError(null);
|
|
||||||
})
|
|
||||||
.catch((e: Error) => setError(e.message)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [spinning, refresh] = useSpin(load);
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (!data?.available) return null;
|
|
||||||
const rows = data.users.filter((u) => u.running);
|
|
||||||
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
|
|
||||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="card" data-testid="member-tmux">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
|
||||||
<TerminalSquare className="w-4 h-4 text-fg-muted" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{t("tmux.description", { version: data.installed ?? "?" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={spinning}
|
|
||||||
aria-busy={spinning}
|
|
||||||
title={t("tmux.refresh")}
|
|
||||||
aria-label={t("tmux.refresh")}
|
|
||||||
className="btn-icon"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
|
||||||
</button>
|
|
||||||
{isAdmin && idleOutdated.length > 0 && (
|
|
||||||
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
|
|
||||||
<RotateCcw className="w-4 h-4" />{" "}
|
|
||||||
{t("tmux.restartIdle", { count: idleOutdated.length })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!data.matched && (
|
|
||||||
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
|
|
||||||
{t("tmux.notMatched")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[560px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-left">
|
|
||||||
<th className={th}>{t("tmux.user")}</th>
|
|
||||||
<th className={th}>{t("tmux.version")}</th>
|
|
||||||
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
|
|
||||||
<th className={th}>{t("tmux.agents")}</th>
|
|
||||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{rows.map((u) => (
|
|
||||||
<tr key={u.id}>
|
|
||||||
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
|
|
||||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
|
||||||
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
|
|
||||||
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
|
||||||
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
|
||||||
{u.startedAt && (
|
|
||||||
<div className="text-[11px] text-fg-muted">
|
|
||||||
{t("tmux.since", {
|
|
||||||
at: formatDateTime(new Date(u.startedAt).toISOString()),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
|
||||||
{u.sessions ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
|
||||||
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
|
|
||||||
</td>
|
|
||||||
{isAdmin && (
|
|
||||||
<td className="px-5 py-2 text-right">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRestarting(u)}
|
|
||||||
title={t("tmux.restart", { name: u.username })}
|
|
||||||
aria-label={t("tmux.restart", { name: u.username })}
|
|
||||||
className="btn-icon"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{restarting && (
|
|
||||||
<RestartDialog
|
|
||||||
target={restarting}
|
|
||||||
idle={idleOutdated}
|
|
||||||
onClose={() => setRestarting(null)}
|
|
||||||
onDone={(msg) => {
|
|
||||||
toast.success(msg);
|
|
||||||
setRestarting(null);
|
|
||||||
void load();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RestartDialog({
|
|
||||||
target,
|
|
||||||
idle,
|
|
||||||
onClose,
|
|
||||||
onDone,
|
|
||||||
}: {
|
|
||||||
target: MemberTmuxRow | "idle";
|
|
||||||
idle: MemberTmuxRow[];
|
|
||||||
onClose: () => void;
|
|
||||||
onDone: (message: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("users");
|
|
||||||
const [current, setCurrent] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const one = target === "idle" ? null : target;
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!current || busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
if (one) {
|
|
||||||
await api.users.tmuxRestart(one.id, current);
|
|
||||||
onDone(t("tmux.done", { name: one.username }));
|
|
||||||
} else {
|
|
||||||
const r = await api.users.tmuxRestartIdle(current);
|
|
||||||
// A server that would not stop needs root: say which, the rest went.
|
|
||||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
|
||||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(errorText(t, e));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfirmModal
|
|
||||||
open
|
|
||||||
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
|
||||||
message={
|
|
||||||
one
|
|
||||||
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
|
||||||
name: one.username,
|
|
||||||
count: one.sessions ?? 0,
|
|
||||||
})
|
|
||||||
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
|
||||||
}
|
|
||||||
confirmLabel={t("tmux.restartButton")}
|
|
||||||
cancelLabel={t("common:cancel")}
|
|
||||||
busy={busy}
|
|
||||||
disabled={!current}
|
|
||||||
onConfirm={() => void submit()}
|
|
||||||
onCancel={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="mt-3 space-y-3"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (isSubmitEnter(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
void submit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="input w-full text-sm"
|
|
||||||
value={current}
|
|
||||||
onChange={(e) => setCurrent(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
aria-label={t("yourPassword")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ConfirmModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
|
||||||
<div className="mt-1">{children}</div>
|
|
||||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* @file UserDetail.tsx
|
||||||
|
* @description One account, opened IN PLACE under its row of the
|
||||||
|
* Administration page's users table (`?user=<id>`, `GET /api/users/:id/details`).
|
||||||
|
* The row already shows identity, sessions, spend, home and tmux, so
|
||||||
|
* this keeps only what the row cannot: one summary line (last sign-in, last
|
||||||
|
* activity, active sessions and live panes), then three short columns — spend
|
||||||
|
* (30-day bars, by AI tool, top models), the account pool (per tool: pin or
|
||||||
|
* group, each account's quota left beside this user's own 7-day share), and
|
||||||
|
* what runs now (live panes, owned workspaces) — and the five newest sessions. Read-only; Edit stays on
|
||||||
|
* the row.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertCircle, Play, RefreshCw } from "lucide-react";
|
||||||
|
import { api, type PoolTool, type UserDetails } from "../../lib/api";
|
||||||
|
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||||
|
import { effectiveSessionStatus, sessionAwaitingReason } from "../../lib/types";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
import { SessionStatusBadge } from "../StatusBadge";
|
||||||
|
import { MiniChart } from "../admin/MiniChart";
|
||||||
|
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
||||||
|
|
||||||
|
const POOL_LABEL: Record<PoolTool, string> = {
|
||||||
|
claude: "Claude Code",
|
||||||
|
codex: "Codex",
|
||||||
|
cursor: "Cursor CLI",
|
||||||
|
};
|
||||||
|
const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||||
|
|
||||||
|
export function UserDetail({ userId }: { userId: number }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const { label: toolLabel } = useAiTools();
|
||||||
|
const [data, setData] = useState<UserDetails | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
() =>
|
||||||
|
api.users
|
||||||
|
.details(userId)
|
||||||
|
.then((d) => {
|
||||||
|
setData(d);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message)),
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
useEffect(() => {
|
||||||
|
setData(null);
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="user-detail"
|
||||||
|
className="flex flex-wrap items-center gap-3 text-sm text-status-danger"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
{t("detail.failed")}: {error}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||||
|
{t("common:retry")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!data)
|
||||||
|
return (
|
||||||
|
<p data-testid="user-detail" className="py-4 text-center text-sm text-fg-muted">
|
||||||
|
{t("common:loading")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
const u = data.user;
|
||||||
|
const total30 = data.usage.last30d.cost;
|
||||||
|
const pools = (Object.keys(POOL_LABEL) as PoolTool[]).filter(
|
||||||
|
(tool) => (data.pool[tool]?.accounts.length ?? 0) > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="user-detail">
|
||||||
|
{/* One line of what the row does not show. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs">
|
||||||
|
<Inline label={t("detail.lastLogin")}>
|
||||||
|
{u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("detail.never")}
|
||||||
|
</Inline>
|
||||||
|
<Inline label={t("detail.lastSession")}>
|
||||||
|
{data.sessions.lastAt ? formatDateTime(data.sessions.lastAt) : "—"}
|
||||||
|
</Inline>
|
||||||
|
<Inline label={t("detail.activeLabel")}>
|
||||||
|
{t("detail.activeValue", { sessions: data.sessions.active, panes: data.panes.length })}
|
||||||
|
</Inline>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
|
className="btn-ghost ml-auto py-1 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||||
|
{t("common:refresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
{/* Spend: the month at a glance, then where it went. */}
|
||||||
|
<Panel title={t("detail.spendDaily")}>
|
||||||
|
<MiniChart
|
||||||
|
kind="bar"
|
||||||
|
label={t("detail.spendDaily")}
|
||||||
|
points={data.usage.daily.map((d) => ({ x: d.date.slice(5), y: d.cost }))}
|
||||||
|
format={(v) => fmtCost(v)}
|
||||||
|
/>
|
||||||
|
{data.usage.byTool.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("detail.noUsage")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5 pt-1">
|
||||||
|
{data.usage.byTool.map((r) => (
|
||||||
|
<ShareRow
|
||||||
|
key={r.key}
|
||||||
|
label={
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AiToolIcon
|
||||||
|
tool={{ id: r.key, label: toolLabel(r.key) }}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{toolLabel(r.key)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
cost={r.cost}
|
||||||
|
total={total30}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{data.usage.byModel.slice(0, 3).map((r) => (
|
||||||
|
<ShareRow
|
||||||
|
key={`m:${r.key}`}
|
||||||
|
label={<span className="font-mono text-[11px] text-fg-muted">{r.key}</span>}
|
||||||
|
cost={r.cost}
|
||||||
|
total={total30}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Pool: what they may run on, and how much of it is theirs. */}
|
||||||
|
<Panel title={t("detail.pool")}>
|
||||||
|
{pools.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("detail.poolNone")}</p>
|
||||||
|
) : (
|
||||||
|
pools.map((tool) => {
|
||||||
|
const p = data.pool[tool]!;
|
||||||
|
return (
|
||||||
|
<div key={tool} className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<AiToolIcon tool={{ id: tool, label: POOL_LABEL[tool] }} className="h-4 w-4" />
|
||||||
|
<span className="font-medium text-fg-primary">{POOL_LABEL[tool]}</span>
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
·{" "}
|
||||||
|
{p.pin
|
||||||
|
? t("detail.poolPinned", { account: p.pin })
|
||||||
|
: p.choice
|
||||||
|
? t("detail.poolChosen", { account: p.choice })
|
||||||
|
: p.group
|
||||||
|
? t("detail.poolGroup", { name: p.group.name })
|
||||||
|
: t("detail.poolShared")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{p.accounts.map((a) => {
|
||||||
|
const share = a.spend7d > 0 ? (a.userSpend7d / a.spend7d) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.name}
|
||||||
|
className={`rounded-md border border-border px-2.5 py-2 space-y-1 ${a.allowed ? "" : "opacity-60"}`}
|
||||||
|
data-testid="user-pool-account"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="truncate font-medium text-fg-primary">
|
||||||
|
{a.name}
|
||||||
|
{a.expired && (
|
||||||
|
<span className="ml-1.5 text-status-danger">{t("pool.expired")}</span>
|
||||||
|
)}
|
||||||
|
{!a.allowed && (
|
||||||
|
<span className="ml-1.5 text-fg-muted">
|
||||||
|
{t("detail.poolNotAllowed")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{tool !== "cursor" && (
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 tabular-nums text-fg-secondary"
|
||||||
|
title={t("detail.poolTotal7d", { cost: fmtCost(a.spend7d) })}
|
||||||
|
>
|
||||||
|
{t("detail.poolTheirs7d", { cost: fmtCost(a.userSpend7d) })}
|
||||||
|
{a.spend7d > 0 && (
|
||||||
|
<span className="text-fg-muted"> ({pctText(share)})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tool !== "cursor" &&
|
||||||
|
windowsOf(a).map((w) => <QuotaBar key={w.key} window={w} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Now: live panes and the workspaces they own. */}
|
||||||
|
<Panel title={t("detail.nowTitle")}>
|
||||||
|
{data.panes.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("detail.noPanes")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{data.panes.map((p) => (
|
||||||
|
<li key={p.id} className="text-xs">
|
||||||
|
<Link
|
||||||
|
to={`/workspace?lane=${p.laneId}&run=${encodeURIComponent(p.id)}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-fg-primary hover:text-accent"
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3 text-status-success" />
|
||||||
|
{p.laneTitle || p.laneCwd}
|
||||||
|
</Link>
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{" "}
|
||||||
|
· {toolLabel(p.tool)}
|
||||||
|
{p.account ? ` · ${p.account}` : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{data.workspaces.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 border-t border-border pt-2">
|
||||||
|
{data.workspaces.map((w) => (
|
||||||
|
<Link
|
||||||
|
key={w.id}
|
||||||
|
to={`/workspace?lane=${w.id}`}
|
||||||
|
title={w.cwd}
|
||||||
|
className="badge py-0.5 text-[11px] hover:text-accent"
|
||||||
|
>
|
||||||
|
{w.livePanes > 0 && (
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||||
|
)}
|
||||||
|
{w.title || w.cwd.split("/").pop()}
|
||||||
|
<span className="ml-1 text-fg-muted">{w.sessions}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.recentSessions.length > 0 && (
|
||||||
|
<Panel title={t("detail.recentTitle")}>
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{data.recentSessions.slice(0, 5).map((s) => (
|
||||||
|
<li key={s.id} className="flex items-center gap-3 py-1.5 text-xs">
|
||||||
|
<SessionStatusBadge
|
||||||
|
status={effectiveSessionStatus(s)}
|
||||||
|
reason={sessionAwaitingReason(s)}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
to={`/sessions/${s.id}`}
|
||||||
|
className="min-w-0 flex-1 truncate text-fg-primary hover:text-accent"
|
||||||
|
>
|
||||||
|
{s.name || s.id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
<span className="flex-shrink-0 text-fg-muted">{toolLabel(s.tool || "claude")}</span>
|
||||||
|
<span className="w-32 flex-shrink-0 text-right text-fg-muted">
|
||||||
|
{formatDateTime(s.last_activity || s.started_at)}
|
||||||
|
</span>
|
||||||
|
<span className="w-16 flex-shrink-0 text-right font-mono text-fg-secondary">
|
||||||
|
{s.cost ? fmtCost(s.cost) : "—"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inline({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span className="text-fg-muted">{label}: </span>
|
||||||
|
<span className="text-fg-primary">{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="card p-3 space-y-2.5 min-w-0">
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One slice of the 30-day spend: label, $, and a thin bar of its share. */
|
||||||
|
function ShareRow({ label, cost, total }: { label: ReactNode; cost: number; total: number }) {
|
||||||
|
const share = total > 0 ? (cost / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<li className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="min-w-0 truncate text-fg-secondary">{label}</span>
|
||||||
|
<span className="flex-shrink-0 tabular-nums text-fg-primary">
|
||||||
|
{fmtCost(cost)} <span className="text-fg-muted">{Math.round(share)}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="block h-1 overflow-hidden rounded-full bg-surface-4">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-accent-solid"
|
||||||
|
style={{ width: `${Math.min(100, share)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @file AccountPoolCard.test.tsx
|
* @file AccountPoolCard.test.tsx
|
||||||
* @description The Users page's account pool card: lists the Claude and Codex
|
* @description The Users page's account pool card: the pools board with every
|
||||||
* accounts with their live panes and quota, says what an empty pool means, an
|
* tool's accounts (live panes and quota) and members per pool, then a tab per
|
||||||
|
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
|
||||||
|
* other installed tool; an
|
||||||
* admin adds an account only with their own password typed (the token goes
|
* admin adds an account only with their own password typed (the token goes
|
||||||
* in once and is not shown back) — a saved login moved in (the server's own
|
* in once and is not shown back) — a saved login moved in (the server's own
|
||||||
* included), or a setup-token — and removes one the same way, and an analyst
|
* included), or a setup-token — and removes one the same way, and an analyst
|
||||||
* sees no add or remove control. An account's Edit ticks the users who run on
|
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||||
* it, saved with the admin's own password; the users show on the account.
|
* it, saved with the admin's own password; the users show on the account.
|
||||||
|
* The board moves members and accounts between pools (drag, or a column's
|
||||||
|
* menu) — an account sits in one pool, so a move takes it out of the other —
|
||||||
|
* splits an account off onto New pool, merges a pool by dragging its only
|
||||||
|
* account into another, adds a saved profile to a pool, shows each account's
|
||||||
|
* plan, and saves every change with ONE password.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import type { AccountPool } from "../../../lib/api";
|
import type { AccountPool } from "../../../lib/api";
|
||||||
|
|
||||||
const accountPool = vi.hoisted(() => ({
|
const accountPool = vi.hoisted(() => ({
|
||||||
@@ -21,15 +29,31 @@ const accountPool = vi.hoisted(() => ({
|
|||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
profiles: vi.fn(),
|
profiles: vi.fn(),
|
||||||
setUsers: vi.fn(),
|
setUsers: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
updateGroup: vi.fn(),
|
||||||
|
deleteGroup: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
|
const aiTools = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
ompSharing: vi.fn(),
|
||||||
|
setOmpSharing: vi.fn(),
|
||||||
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||||
|
}));
|
||||||
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: { accountPool, users } };
|
return { ...actual, api: { accountPool, users, aiTools } };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { AccountPoolCard } from "../AccountPoolCard";
|
import { AccountPoolCard } from "../AccountPoolCard";
|
||||||
|
|
||||||
|
/** The card reads `?poolAdd=` and links to Settings: it needs a router. */
|
||||||
|
const card = (isAdmin: boolean, url = "/admin") => (
|
||||||
|
<MemoryRouter initialEntries={[url]}>
|
||||||
|
<AccountPoolCard isAdmin={isAdmin} />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
const POOL: AccountPool = {
|
const POOL: AccountPool = {
|
||||||
accounts: [
|
accounts: [
|
||||||
{
|
{
|
||||||
@@ -45,9 +69,44 @@ const POOL: AccountPool = {
|
|||||||
spend7d: 12,
|
spend7d: 12,
|
||||||
tokens5h: 250_000,
|
tokens5h: 250_000,
|
||||||
tokens7d: 3_400_000,
|
tokens7d: 3_400_000,
|
||||||
|
quota: {
|
||||||
|
fiveHour: {
|
||||||
|
utilization: 42,
|
||||||
|
resetsAt: new Date(Date.now() + 2 * 3600e3 + 630e3).toISOString(),
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
utilization: 15,
|
||||||
|
resetsAt: new Date(Date.now() + 3 * 86400e3 + 3630e3).toISOString(),
|
||||||
|
},
|
||||||
|
checkedAt: "2026-10-01T00:00:00Z",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
quotaPercent: 42,
|
quotaPercent: 42,
|
||||||
full: false,
|
full: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "team-2",
|
||||||
|
kind: "token",
|
||||||
|
email: null,
|
||||||
|
refreshError: null,
|
||||||
|
addedAt: "2026-10-01T00:00:00Z",
|
||||||
|
expiresAt: null,
|
||||||
|
expired: false,
|
||||||
|
livePanes: 0,
|
||||||
|
spend5h: 0,
|
||||||
|
spend7d: 0,
|
||||||
|
tokens5h: 0,
|
||||||
|
tokens7d: 0,
|
||||||
|
// A 5-hour window that has reset since the reading is full again.
|
||||||
|
quota: {
|
||||||
|
fiveHour: { utilization: 100, resetsAt: "2020-01-01T00:00:00Z" },
|
||||||
|
sevenDay: null,
|
||||||
|
checkedAt: "2026-10-01T00:00:00Z",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
quotaPercent: null,
|
||||||
|
full: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
codex: [
|
codex: [
|
||||||
{
|
{
|
||||||
@@ -66,13 +125,57 @@ const POOL: AccountPool = {
|
|||||||
blockPercent: 90,
|
blockPercent: 90,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tool = (id: string, label: string, installed = true) => ({ id, label, installed });
|
||||||
|
const OMP = {
|
||||||
|
available: true,
|
||||||
|
enabled: false,
|
||||||
|
installed: true,
|
||||||
|
broker: { running: false },
|
||||||
|
gateway: { running: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The account names a pool column holds (its move menu names others too). */
|
||||||
|
const held = (col: HTMLElement) =>
|
||||||
|
within(col)
|
||||||
|
.queryAllByTestId("pool-group-account")
|
||||||
|
.map((c) => within(c).queryByText(/^(team|cx)-\d$/)?.textContent);
|
||||||
|
|
||||||
|
/** A drag-and-drop between two elements, through one shared DataTransfer. */
|
||||||
|
function drag(from: Element, to: Element) {
|
||||||
|
const store: Record<string, string> = {};
|
||||||
|
const dataTransfer = {
|
||||||
|
setData: (k: string, v: string) => (store[k] = v),
|
||||||
|
getData: (k: string) => store[k] ?? "",
|
||||||
|
get types() {
|
||||||
|
return Object.keys(store);
|
||||||
|
},
|
||||||
|
effectAllowed: "",
|
||||||
|
};
|
||||||
|
fireEvent.dragStart(from, { dataTransfer });
|
||||||
|
fireEvent.dragOver(to, { dataTransfer });
|
||||||
|
fireEvent.drop(to, { dataTransfer });
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||||
|
aiTools.list.mockResolvedValue({
|
||||||
|
tools: [
|
||||||
|
tool("claude", "Claude Code"),
|
||||||
|
tool("codex", "Codex"),
|
||||||
|
tool("cursor", "Cursor CLI"),
|
||||||
|
tool("omp", "Oh My Pi"),
|
||||||
|
tool("antigravity", "Antigravity"),
|
||||||
|
tool("goose", "Goose", false),
|
||||||
|
],
|
||||||
|
default: "claude",
|
||||||
|
});
|
||||||
|
aiTools.ompSharing.mockResolvedValue(OMP);
|
||||||
|
aiTools.setOmpSharing.mockResolvedValue({ ...OMP, enabled: true });
|
||||||
accountPool.list.mockResolvedValue(POOL);
|
accountPool.list.mockResolvedValue(POOL);
|
||||||
users.list.mockResolvedValue({
|
users.list.mockResolvedValue({
|
||||||
users: [
|
users: [
|
||||||
{ id: 7, username: "mia", disabled: false },
|
{ id: 7, username: "mia", role: "developer", disabled: false },
|
||||||
{ id: 8, username: "leo", disabled: false },
|
{ id: 8, username: "leo", role: "analyst", disabled: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
accountPool.profiles.mockResolvedValue({
|
accountPool.profiles.mockResolvedValue({
|
||||||
@@ -90,28 +193,275 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AccountPoolCard", () => {
|
describe("AccountPoolCard", () => {
|
||||||
it("lists every account, and explains an empty pool", async () => {
|
it("shows every tool's accounts on the board, and a tab per tool with no pool", async () => {
|
||||||
const { unmount } = render(<AccountPoolCard isAdmin={false} />);
|
const user = userEvent.setup();
|
||||||
|
render(card(false));
|
||||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
// With no pool made yet, every account sits in the shared pool, by tool.
|
||||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
const shared = screen.getByTestId("pool-shared");
|
||||||
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
|
expect(shared).toHaveTextContent("Claude Code2");
|
||||||
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
|
expect(shared).toHaveTextContent("Codex1");
|
||||||
|
expect(shared).toHaveTextContent("cx-1");
|
||||||
|
const [account, second] = screen.getAllByTestId("pool-account");
|
||||||
|
// Every account of the pool has its card; quota is shown as what is LEFT.
|
||||||
|
expect(second).toHaveTextContent("team-2");
|
||||||
|
const quota = screen.getAllByTestId("pool-quota")[0]!;
|
||||||
|
expect(quota).toHaveTextContent("5 hours");
|
||||||
|
expect(quota).toHaveTextContent("58%");
|
||||||
|
expect(quota).toHaveTextContent("resets in 2h 10m");
|
||||||
|
expect(quota).toHaveTextContent("Week");
|
||||||
|
expect(quota).toHaveTextContent("85%");
|
||||||
|
expect(quota).toHaveTextContent("resets in 3d 1h");
|
||||||
|
const reset = screen.getAllByTestId("pool-quota")[1]!;
|
||||||
|
expect(reset).toHaveTextContent("100%");
|
||||||
|
expect(reset).toHaveTextContent("—");
|
||||||
|
expect(account).toHaveTextContent("250.0K tokens");
|
||||||
|
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
||||||
|
expect(account).toHaveTextContent("2 live");
|
||||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
|
// An analyst reads: no add, remove or new pool.
|
||||||
|
expect(screen.queryByTestId("pool-add-account")).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||||
unmount();
|
expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
|
||||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
// Pool tools have no tab any more; an installed tool without a pool does.
|
||||||
render(<AccountPoolCard isAdmin />);
|
expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
|
||||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
||||||
|
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a Cursor API key from Add account", async () => {
|
||||||
|
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
||||||
|
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
await screen.findByText("team-1");
|
||||||
|
await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(screen.queryByLabelText("From")).toBeNull();
|
||||||
|
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||||
|
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
|
||||||
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(
|
||||||
|
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.add).toHaveBeenCalledWith(
|
||||||
|
{ tool: "cursor", name: "cu-1", token: "key_abc" },
|
||||||
|
"boss-pass"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
|
||||||
|
accountPool.list.mockResolvedValue({
|
||||||
|
...POOL,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Team A",
|
||||||
|
users: [{ userId: 8, username: "leo" }],
|
||||||
|
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
||||||
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||||
|
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
const teamA = await screen.findByTestId("pool-group");
|
||||||
|
expect(teamA).toHaveTextContent("leo");
|
||||||
|
expect(teamA).toHaveTextContent("team-1");
|
||||||
|
await screen.findByText("mia");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "New pool" }));
|
||||||
|
const names = screen.getAllByLabelText("Pool name");
|
||||||
|
await user.type(names[names.length - 1]!, "Team B");
|
||||||
|
const teamB = screen.getAllByTestId("pool-group")[1]!;
|
||||||
|
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
|
||||||
|
expect(teamB).toHaveTextContent("mia");
|
||||||
|
const add = within(teamB).getByLabelText("Move an account into Team B");
|
||||||
|
await user.selectOptions(add, "codex|cx-1");
|
||||||
|
await user.selectOptions(add, "claude|profile:work");
|
||||||
|
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
|
||||||
|
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
|
||||||
|
expect(accountPool.updateGroup).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||||
|
);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
|
||||||
|
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
|
||||||
|
"boss-pass"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(accountPool.add).toHaveBeenCalledWith(
|
||||||
|
{ tool: "claude", name: "work", profile: "work" },
|
||||||
|
"boss-pass"
|
||||||
|
);
|
||||||
|
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||||
|
// Team A did not change: nothing is sent for it.
|
||||||
|
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
|
||||||
|
accountPool.list.mockResolvedValue({
|
||||||
|
...POOL,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Team A",
|
||||||
|
users: [{ userId: 8, username: "leo" }],
|
||||||
|
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
const teamA = await screen.findByTestId("pool-group");
|
||||||
|
const shared = screen.getByTestId("pool-shared");
|
||||||
|
const item = (col: HTMLElement, name: string) =>
|
||||||
|
within(col)
|
||||||
|
.getAllByTestId("pool-group-account")
|
||||||
|
.find((c) => c.textContent?.includes(name))!;
|
||||||
|
drag(item(shared, "team-2"), teamA);
|
||||||
|
expect(held(teamA)).toEqual(["team-1", "team-2"]);
|
||||||
|
// And back again: an account is in one pool, so it leaves the other.
|
||||||
|
drag(item(teamA, "team-1"), shared);
|
||||||
|
expect(held(teamA)).toEqual(["team-2"]);
|
||||||
|
expect(held(shared)).toContain("team-1");
|
||||||
|
expect(teamA).toHaveTextContent("team-2");
|
||||||
|
await user.click(
|
||||||
|
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
|
||||||
|
);
|
||||||
|
expect(shared).toHaveTextContent("leo");
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||||
|
);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
name: "Team A",
|
||||||
|
userIds: [],
|
||||||
|
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||||
|
},
|
||||||
|
"boss-pass"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows each account's plan on its card", async () => {
|
||||||
|
accountPool.list.mockResolvedValue({
|
||||||
|
...POOL,
|
||||||
|
accounts: [{ ...POOL.accounts[0]!, plan: "Max 5x" }, POOL.accounts[1]!],
|
||||||
|
});
|
||||||
|
render(card(false));
|
||||||
|
const [first, second] = await screen.findAllByTestId("pool-account");
|
||||||
|
expect(within(first!).getByTestId("pool-account-plan")).toHaveTextContent("Max 5x");
|
||||||
|
expect(within(second!).queryByTestId("pool-account-plan")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
|
||||||
|
accountPool.list.mockResolvedValue({
|
||||||
|
...POOL,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Team A",
|
||||||
|
users: [{ userId: 8, username: "leo" }],
|
||||||
|
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Team B",
|
||||||
|
users: [{ userId: 7, username: "mia" }],
|
||||||
|
accounts: { claude: [], codex: ["cx-1"], cursor: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
|
||||||
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||||
|
accountPool.deleteGroup.mockResolvedValue({ ok: true });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
|
||||||
|
await screen.findByText("mia");
|
||||||
|
const item = (col: HTMLElement, name: string) =>
|
||||||
|
within(col)
|
||||||
|
.getAllByTestId("pool-group-account")
|
||||||
|
.find((c) => c.textContent?.includes(name))!;
|
||||||
|
// Split: team-2 leaves the shared pool for a new pool of its own.
|
||||||
|
drag(item(screen.getByTestId("pool-shared"), "team-2"), screen.getByTestId("pool-new"));
|
||||||
|
const pools = screen.getAllByTestId("pool-group");
|
||||||
|
expect(pools).toHaveLength(3);
|
||||||
|
expect(held(pools[2]!)).toEqual(["team-2"]);
|
||||||
|
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
|
||||||
|
// Merge: Team B's only account into Team A brings mia along; Team B goes.
|
||||||
|
drag(item(teamB!, "cx-1"), teamA!);
|
||||||
|
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
|
||||||
|
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
|
||||||
|
expect(teamA).toHaveTextContent("mia");
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||||
|
);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
|
||||||
|
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
|
||||||
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
name: "Team A",
|
||||||
|
userIds: [8, 7],
|
||||||
|
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
||||||
|
},
|
||||||
|
"boss-pass"
|
||||||
|
);
|
||||||
|
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
|
||||||
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||||
|
3,
|
||||||
|
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
|
||||||
|
"boss-pass"
|
||||||
|
);
|
||||||
|
// The members moved before the emptied pool was deleted.
|
||||||
|
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||||
|
fn.mock.invocationCallOrder[0]!;
|
||||||
|
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
await screen.findByText("team-1");
|
||||||
|
await user.click(screen.getByRole("tab", { name: /Oh My Pi/ }));
|
||||||
|
expect(screen.getByTestId("omp-pool-status")).toHaveTextContent(/Sharing is off/);
|
||||||
|
await user.click(screen.getByRole("switch"));
|
||||||
|
await waitFor(() => expect(aiTools.setOmpSharing).toHaveBeenCalledWith(true));
|
||||||
|
expect(screen.getByRole("tab", { name: /Oh My Pi/ })).toHaveTextContent("shared");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves a saved login in, the server's own included", async () => {
|
it("moves a saved login in, the server's own included", async () => {
|
||||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AccountPoolCard isAdmin />);
|
render(card(true));
|
||||||
await screen.findByText("team-1");
|
await screen.findByText("team-1");
|
||||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
const select = await screen.findByLabelText("Profile");
|
const select = await screen.findByLabelText("Profile");
|
||||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||||
@@ -134,9 +484,9 @@ describe("AccountPoolCard", () => {
|
|||||||
it("adds a setup-token only with the admin's own password", async () => {
|
it("adds a setup-token only with the admin's own password", async () => {
|
||||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AccountPoolCard isAdmin />);
|
render(card(true));
|
||||||
await screen.findByText("team-1");
|
await screen.findByText("team-1");
|
||||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||||
@@ -159,7 +509,8 @@ describe("AccountPoolCard", () => {
|
|||||||
it("removes an account with the admin's own password", async () => {
|
it("removes an account with the admin's own password", async () => {
|
||||||
accountPool.remove.mockResolvedValue({ ok: true });
|
accountPool.remove.mockResolvedValue({ ok: true });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AccountPoolCard isAdmin />);
|
render(card(true));
|
||||||
|
await screen.findByText("team-1");
|
||||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||||
@@ -184,7 +535,7 @@ describe("AccountPoolCard", () => {
|
|||||||
removed: [{ userId: 8, username: "leo" }],
|
removed: [{ userId: 8, username: "leo" }],
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AccountPoolCard isAdmin />);
|
render(card(true));
|
||||||
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
||||||
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
||||||
@@ -206,4 +557,39 @@ describe("AccountPoolCard", () => {
|
|||||||
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
|
||||||
|
accountPool.add.mockResolvedValue({ account: { name: "cx" } });
|
||||||
|
accountPool.profiles.mockImplementation(async (tool: string) =>
|
||||||
|
tool === "codex"
|
||||||
|
? {
|
||||||
|
profiles: [
|
||||||
|
{ name: "cx", label: "Codex", email: "c@x", subscriptionType: "plus", active: false },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { profiles: [] }
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||||
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||||
|
(b) => b.textContent === "Add account"
|
||||||
|
)!;
|
||||||
|
await user.click(confirm);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.add).toHaveBeenCalledWith(
|
||||||
|
{ tool: "codex", name: "cx", profile: "cx" },
|
||||||
|
"boss-pass"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links back to the profiles in Settings", async () => {
|
||||||
|
render(card(false));
|
||||||
|
const link = await screen.findByRole("link", { name: "Profiles in Settings" });
|
||||||
|
expect(link.getAttribute("href")).toBe("/settings/agents#tool-profiles");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+57
-21
@@ -1,18 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* @file MemberTmuxCard.test.tsx
|
* @file MemberTmux.test.tsx
|
||||||
* @description The Users page's members' tmux card: hidden without one Linux
|
* @description Each member's tmux server as part of the users table
|
||||||
* user per account, lists each running member server with its version and a
|
* (`MemberTmux.tsx`): the bar above the table is hidden without one Linux
|
||||||
* badge when it is older than the installed tmux, warns until tmux-member is
|
* user per account, names the installed tmux and warns until tmux-member is
|
||||||
* installed, and an admin restarts one member — or every idle outdated one,
|
* installed; a row's cell shows the version with a badge when it is older than
|
||||||
* the working member left out — only with their own password typed; an
|
* the installed tmux, and "—" for a member with no server; an admin restarts
|
||||||
* analyst sees no restart control.
|
* one member — or every idle outdated one, the working member left out — only
|
||||||
|
* with their own password typed; an analyst gets no "Restart idle".
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { MemberTmux } from "../../../lib/api";
|
import type { MemberTmux, MemberTmuxRow } from "../../../lib/api";
|
||||||
|
|
||||||
const users = vi.hoisted(() => ({
|
const users = vi.hoisted(() => ({
|
||||||
tmux: vi.fn(),
|
tmux: vi.fn(),
|
||||||
@@ -25,7 +27,41 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
import { MemberTmuxCard } from "../MemberTmuxCard";
|
import { MemberTmuxBar, TmuxCell, TmuxRestartDialog, useMemberTmux } from "../MemberTmux";
|
||||||
|
|
||||||
|
/** The users table's use of it, reduced: the bar, one cell and restart per row. */
|
||||||
|
function Harness({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const { data, reload } = useMemberTmux();
|
||||||
|
const [target, setTarget] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MemberTmuxBar data={data} isAdmin={isAdmin} onRestart={setTarget} />
|
||||||
|
{data?.available &&
|
||||||
|
data.users.map((u) => (
|
||||||
|
<div key={u.id} data-testid={`row-${u.username}`}>
|
||||||
|
<span>{u.username}</span>
|
||||||
|
<TmuxCell row={u} />
|
||||||
|
{isAdmin && u.running && (
|
||||||
|
<button type="button" onClick={() => setTarget(u)}>
|
||||||
|
Restart {u.username}'s tmux
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{target && (
|
||||||
|
<TmuxRestartDialog
|
||||||
|
target={target}
|
||||||
|
data={data}
|
||||||
|
onClose={() => setTarget(null)}
|
||||||
|
onDone={() => {
|
||||||
|
setTarget(null);
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const DATA: MemberTmux = {
|
const DATA: MemberTmux = {
|
||||||
available: true,
|
available: true,
|
||||||
@@ -52,33 +88,33 @@ beforeEach(() => {
|
|||||||
users.tmux.mockResolvedValue(DATA);
|
users.tmux.mockResolvedValue(DATA);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("MemberTmuxCard", () => {
|
describe("MemberTmux", () => {
|
||||||
it("renders nothing without one Linux user per account", async () => {
|
it("shows no bar without one Linux user per account", async () => {
|
||||||
users.tmux.mockResolvedValue({ available: false, users: [] });
|
users.tmux.mockResolvedValue({ available: false, users: [] });
|
||||||
const { container } = render(<MemberTmuxCard isAdmin />);
|
render(<Harness isAdmin />);
|
||||||
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
|
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
|
||||||
expect(container).toBeEmptyDOMElement();
|
expect(screen.queryByTestId("member-tmux")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists running servers and marks the ones older than the installed tmux", async () => {
|
it("marks the servers older than the installed tmux, and a member with none", async () => {
|
||||||
render(<MemberTmuxCard isAdmin={false} />);
|
render(<Harness isAdmin={false} />);
|
||||||
expect(await screen.findByText("mia")).toBeInTheDocument();
|
expect(await screen.findByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText("bob")).toBeNull();
|
|
||||||
expect(screen.getAllByText("older than installed")).toHaveLength(2);
|
expect(screen.getAllByText("older than installed")).toHaveLength(2);
|
||||||
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
expect(screen.getByTestId("row-mia").textContent).toContain("2 sessions");
|
||||||
|
expect(screen.getByTestId("row-bob").textContent).toContain("—");
|
||||||
// An analyst reads only.
|
// An analyst reads only.
|
||||||
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
|
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns until tmux-member is installed", async () => {
|
it("warns until tmux-member is installed", async () => {
|
||||||
users.tmux.mockResolvedValue({ ...DATA, matched: false });
|
users.tmux.mockResolvedValue({ ...DATA, matched: false });
|
||||||
render(<MemberTmuxCard isAdmin />);
|
render(<Harness isAdmin />);
|
||||||
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restarts one member only with the admin's password", async () => {
|
it("restarts one member only with the admin's password", async () => {
|
||||||
users.tmuxRestart.mockResolvedValue({});
|
users.tmuxRestart.mockResolvedValue({});
|
||||||
render(<MemberTmuxCard isAdmin />);
|
render(<Harness isAdmin />);
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
||||||
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
||||||
const go = screen.getByRole("button", { name: "Restart" });
|
const go = screen.getByRole("button", { name: "Restart" });
|
||||||
@@ -90,7 +126,7 @@ describe("MemberTmuxCard", () => {
|
|||||||
|
|
||||||
it("restarts every idle outdated member at once, the working one left out", async () => {
|
it("restarts every idle outdated member at once, the working one left out", async () => {
|
||||||
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
|
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
|
||||||
render(<MemberTmuxCard isAdmin />);
|
render(<Harness isAdmin />);
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
|
||||||
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
||||||
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||||
@@ -17,6 +17,7 @@ import { api } from "../../lib/api";
|
|||||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
import { laneChangeMatcher } from "../../lib/laneChange";
|
import { laneChangeMatcher } from "../../lib/laneChange";
|
||||||
import type { PipelineStats } from "../../lib/types";
|
import type { PipelineStats } from "../../lib/types";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
|
||||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||||
function pct(n: number, total: number): number {
|
function pct(n: number, total: number): number {
|
||||||
@@ -160,7 +161,7 @@ export function LanePipelinePanel({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid={`pipeline-stat-command-${n.id}`}
|
data-testid={`pipeline-stat-command-${n.id}`}
|
||||||
title={t("lanes.copyCommand", { command: n.command })}
|
title={t("lanes.copyCommand", { command: n.command })}
|
||||||
onClick={() => void navigator.clipboard?.writeText(n.command || "")}
|
onClick={() => void copyText(n.command || "")}
|
||||||
className="block max-w-full truncate font-mono text-[10px] text-fg-muted hover:text-accent"
|
className="block max-w-full truncate font-mono text-[10px] text-fg-muted hover:text-accent"
|
||||||
>
|
>
|
||||||
{n.command}
|
{n.command}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @file useCcTarget — whose Claude Code config the Config Explorer manages for
|
||||||
|
* this login (GET /api/cc-config/target). Local mode and an admin: the host's,
|
||||||
|
* answered at once without a request (nothing changes for them). A non-admin
|
||||||
|
* in server mode asks the server: `own` when they have their own Linux account
|
||||||
|
* (S3) — Settings then shows "Your Claude Code config" — else `none` and the
|
||||||
|
* page stays hidden. The server's refusal (403 ENOACCOUNT/EADMIN) is the rule;
|
||||||
|
* this only hides what would be refused.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type CcTarget } from "../lib/api";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
|
|
||||||
|
const HOST: CcTarget = { kind: "host", readOnly: false, claudeHome: null };
|
||||||
|
|
||||||
|
/** `null` while a non-admin's answer is on its way. */
|
||||||
|
export function useCcTarget(): CcTarget | null {
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
const [target, setTarget] = useState<CcTarget | null>(isAdmin ? HOST : null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
setTarget(HOST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
api.ccConfig
|
||||||
|
.target()
|
||||||
|
.then((t) => {
|
||||||
|
if (live) setTarget(t);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (live) setTarget({ kind: "none", readOnly: true, claudeHome: null });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [isAdmin]);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@@ -27,9 +27,9 @@ const FOCUSABLE =
|
|||||||
*/
|
*/
|
||||||
export function useDialog(
|
export function useDialog(
|
||||||
open: boolean,
|
open: boolean,
|
||||||
panelRef: RefObject<HTMLElement>,
|
panelRef: RefObject<HTMLElement | null>,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
initialFocus?: RefObject<HTMLElement>
|
initialFocus?: RefObject<HTMLElement | null>
|
||||||
): void {
|
): void {
|
||||||
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
||||||
// would steal focus back to the first control on every parent render.
|
// would steal focus back to the first control on every parent render.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type MessageHandler = (msg: WSMessage) => void;
|
|||||||
* auto-reconnects with capped exponential backoff on close - plus an
|
* auto-reconnects with capped exponential backoff on close - plus an
|
||||||
* immediate reconnect attempt on tab focus/network-online/visibility-change
|
* immediate reconnect attempt on tab focus/network-online/visibility-change
|
||||||
* so the socket recovers quickly after a server restart or laptop sleep.
|
* so the socket recovers quickly after a server restart or laptop sleep.
|
||||||
* Guards against React 18 StrictMode's mount→cleanup→remount cycle opening a
|
* Guards against React StrictMode's mount→cleanup→remount cycle opening a
|
||||||
* duplicate socket (see the inline comment in `connect`).
|
* duplicate socket (see the inline comment in `connect`).
|
||||||
* @param onMessage Called with every message parsed from the socket; the
|
* @param onMessage Called with every message parsed from the socket; the
|
||||||
* latest reference is used even across reconnects (no stale closures).
|
* latest reference is used even across reconnects (no stale closures).
|
||||||
@@ -73,7 +73,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const handlersRef = useRef<MessageHandler>(onMessage);
|
const handlersRef = useRef<MessageHandler>(onMessage);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const reconnectAttempts = useRef(0);
|
const reconnectAttempts = useRef(0);
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
|||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
// Don't open a second socket if one is already alive or in flight.
|
// Don't open a second socket if one is already alive or in flight.
|
||||||
// Without this, React 18 StrictMode (mount → cleanup → remount in dev)
|
// Without this, React StrictMode (mount → cleanup → remount in dev)
|
||||||
// and the close→reconnect race could leave two sockets connected at the
|
// and the close→reconnect race could leave two sockets connected at the
|
||||||
// same time. Both would receive every server broadcast, producing
|
// same time. Both would receive every server broadcast, producing
|
||||||
// duplicate stream_event deltas (doubled text, duplicate assistant
|
// duplicate stream_event deltas (doubled text, duplicate assistant
|
||||||
|
|||||||
@@ -1,37 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "Administration",
|
"title": "Administration",
|
||||||
"description": "Accounts and the server AgentHub runs on.",
|
"description": "Accounts and the server AgentHub runs on."
|
||||||
"tabs": {
|
|
||||||
"users": "Users",
|
|
||||||
"server": "Server"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"title": "Server",
|
|
||||||
"uptime": "up {{v}}",
|
|
||||||
"uptimeDays": "{{d}} d {{h}} h",
|
|
||||||
"uptimeHours": "{{h}} h {{m}} min",
|
|
||||||
"cpu": "CPU",
|
|
||||||
"cores": "{{n}} cores · load {{load}}",
|
|
||||||
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
|
||||||
"memory": "Memory",
|
|
||||||
"swap": "Swap",
|
|
||||||
"homesDisk": "Homes disk",
|
|
||||||
"homesDiskHelp": "The disk that holds the users' home folders.",
|
|
||||||
"free": "free",
|
|
||||||
"usageTitle": "Usage by user",
|
|
||||||
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
|
|
||||||
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
|
||||||
"who": "User",
|
|
||||||
"processes": "Processes",
|
|
||||||
"home": "Home",
|
|
||||||
"sampling": "Sampling…",
|
|
||||||
"hostGroup": "Host account (AgentHub and other host processes)",
|
|
||||||
"systemGroup": "System (root and other accounts)",
|
|
||||||
"disks": "Disks",
|
|
||||||
"mount": "Mount",
|
|
||||||
"device": "Device",
|
|
||||||
"used": "Used",
|
|
||||||
"homesBadge": "homes",
|
|
||||||
"gpus": "GPUs"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"noPreview": "This file type cannot be shown here — download it to open it.",
|
"noPreview": "This file type cannot be shown here — download it to open it.",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
"installs": "{{count}} installs",
|
|
||||||
"installed": "Installed to {{path}}",
|
"installed": "Installed to {{path}}",
|
||||||
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
||||||
"rename": "Edit",
|
"rename": "Edit",
|
||||||
@@ -183,5 +182,46 @@
|
|||||||
"stat.updated": "Updated",
|
"stat.updated": "Updated",
|
||||||
"detail.runAs": "Run it as",
|
"detail.runAs": "Run it as",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Added {{date}}"
|
"detail.added": "Added {{date}}",
|
||||||
|
"tab.unowned": "Unowned",
|
||||||
|
"tab.suggested": "Is this yours?",
|
||||||
|
"auto": "Auto",
|
||||||
|
"autoHint": "Added when its session stopped: a document the session created or changed",
|
||||||
|
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
|
||||||
|
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
|
||||||
|
"unowned.mine": "It's mine",
|
||||||
|
"unowned.takeIt": "Take it",
|
||||||
|
"unowned.notMine": "Not mine",
|
||||||
|
"unowned.assign": "Assign to…",
|
||||||
|
"unowned.claimed": "Added to your library",
|
||||||
|
"unowned.declined": "Removed from your list",
|
||||||
|
"unowned.assigned": "Owner assigned",
|
||||||
|
"unowned.empty": "Nothing without an owner.",
|
||||||
|
"installs_one": "{{count}} install",
|
||||||
|
"installs_other": "{{count}} installs",
|
||||||
|
"private": "Private",
|
||||||
|
"privateHint": "Not shared: only you (and admins) see it. Share puts it in the Hub.",
|
||||||
|
"showMore": "Show {{count}} more",
|
||||||
|
"view.label": "Layout",
|
||||||
|
"view.list": "List view",
|
||||||
|
"view.grid": "Grid view",
|
||||||
|
"pack.title": "{{prefix}}-* pack",
|
||||||
|
"pack.count_one": "{{count}} skill",
|
||||||
|
"pack.count_other": "{{count}} skills",
|
||||||
|
"pack.installAll": "Install all",
|
||||||
|
"pack.installRest": "Install {{count}} more",
|
||||||
|
"pack.installTitle": "Install {{count}} skills of {{prefix}}-*?",
|
||||||
|
"pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:",
|
||||||
|
"pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}",
|
||||||
|
"section.unshared": "Not shared by their owners",
|
||||||
|
"section.unsharedHint": "Other users' private items — only admins see these",
|
||||||
|
"update": "Update",
|
||||||
|
"updateHint": "Its owner changed it since you installed it",
|
||||||
|
"updateTitle": "Update “{{title}}”?",
|
||||||
|
"updateBody": "Your copy of {{name}} is replaced by the newest version. The current one is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||||
|
"updated": "Updated {{name}}",
|
||||||
|
"pack.updateN": "Update {{count}}",
|
||||||
|
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
||||||
|
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
||||||
|
"pack.updated": "Updated {{ok}} · failed {{failed}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "Claude Config Explorer",
|
"title": "Claude Config Explorer",
|
||||||
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
|
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
|
||||||
|
"own": {
|
||||||
|
"label": "Your Claude Code config — {{home}}",
|
||||||
|
"subtitle": "Only your own account's setup: every change here runs as your account and reaches only your own sessions."
|
||||||
|
},
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"refreshing": "Refreshing…",
|
"refreshing": "Refreshing…",
|
||||||
"lastUpdated": "Last updated {{time}}",
|
"lastUpdated": "Last updated {{time}}",
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
|
"claudeHome": "Your .claude",
|
||||||
"projectClaudeDir": "Project .claude",
|
"projectClaudeDir": "Project .claude",
|
||||||
"projectRoot": "Project root",
|
"projectRoot": "Project root",
|
||||||
"claudeJson": "claude.json",
|
"claudeJson": "claude.json",
|
||||||
|
|||||||
@@ -30,14 +30,6 @@
|
|||||||
"since": "waiting {{ago}}"
|
"since": "waiting {{ago}}"
|
||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
"runtime": "Runtime",
|
|
||||||
"cores_one": "{{count}} core",
|
|
||||||
"cores_other": "{{count}} cores",
|
|
||||||
"uptime": "Uptime",
|
|
||||||
"cpu": "CPU (1/5/15m)",
|
|
||||||
"nodeRss": "Node RSS",
|
|
||||||
"hostMemory": "Host Memory",
|
|
||||||
"hostMemoryTip": "Host Memory: {{pct}}% used\n{{used}} / {{total}}",
|
|
||||||
"heap": "V8 Heap",
|
"heap": "V8 Heap",
|
||||||
"heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}",
|
"heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
@@ -50,12 +42,7 @@
|
|||||||
"events": "Events",
|
"events": "Events",
|
||||||
"score": "Health Score",
|
"score": "Health Score",
|
||||||
"formula": "Formula",
|
"formula": "Formula",
|
||||||
"formulaTip": "Composite index formula:\n0.4 × Success rate ({{success}}%)\n+ 0.25 × Cache hit ({{cache}}%)\n+ 0.25 × (100 − Error rate) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
|
"cacheTip": "Transcript cache hit rate: {{pct}}% (shown, not scored — it restarts empty with the server)",
|
||||||
"scoreTip": "Score: {{score}} / 100\n\n• Success rate (40%): {{success}}%\n• Cache hit (25%): {{cache}}%\n• Error avoidance (25%): {{noError}}%\n• Memory health (10%): {{noHeap}}%",
|
|
||||||
"cache": "Cache",
|
|
||||||
"cacheTip": "Cache hit rate: {{pct}}%\nHits: {{hits}}\nMisses: {{misses}}",
|
|
||||||
"errors": "Errors",
|
|
||||||
"errorsTip": "Error rate: {{pct}}%\n<5% = healthy, 5–15% = warning, >15% = critical",
|
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.",
|
"compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
@@ -65,9 +52,10 @@
|
|||||||
"modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%",
|
"modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%",
|
||||||
"noModelData": "No model data",
|
"noModelData": "No model data",
|
||||||
"concurrency": "Concurrency",
|
"concurrency": "Concurrency",
|
||||||
"intervals_one": "{{count}} interval",
|
"intervals_one": "{{count}} agent type",
|
||||||
"intervals_other": "{{count}} intervals",
|
"intervals_other": "{{count}} agent types",
|
||||||
"concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}",
|
"concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}",
|
||||||
|
"laneTip": "{{name}}\n{{count}} agents\nRuns {{start}}–{{end}}% of the session (avg)",
|
||||||
"noConcurrencyData": "No concurrency data",
|
"noConcurrencyData": "No concurrency data",
|
||||||
"peak": "Peak",
|
"peak": "Peak",
|
||||||
"peakTip": "Peak: {{count}} sessions running simultaneously.",
|
"peakTip": "Peak: {{count}} sessions running simultaneously.",
|
||||||
@@ -99,11 +87,101 @@
|
|||||||
"synchronous": "Synchronous",
|
"synchronous": "Synchronous",
|
||||||
"autoVacuum": "Auto-vacuum",
|
"autoVacuum": "Auto-vacuum",
|
||||||
"foreignKeys": "Foreign keys",
|
"foreignKeys": "Foreign keys",
|
||||||
"busyTimeout": "Busy timeout"
|
"busyTimeout": "Busy timeout",
|
||||||
|
"groupApp": "AgentHub",
|
||||||
|
"groupAppHint": "How the dashboard itself is doing: score, database, sessions at once",
|
||||||
|
"groupMachine": "Machine",
|
||||||
|
"groupMachineHint": "The computer AgentHub runs on: CPU, memory, disks, processes",
|
||||||
|
"groupActivity": "Agent activity",
|
||||||
|
"groupActivityHint": "What the agents used: tokens by model, tools, subagents",
|
||||||
|
"scoreFormula": "100 minus what each check costs:",
|
||||||
|
"scoreResult": "= {{score}} / 100",
|
||||||
|
"check": {
|
||||||
|
"loop": "AgentHub response",
|
||||||
|
"cpu": "CPU wait",
|
||||||
|
"memory": "Memory wait",
|
||||||
|
"io": "Disk wait",
|
||||||
|
"ram": "RAM in use",
|
||||||
|
"disk": "Data disk",
|
||||||
|
"heap": "V8 heap",
|
||||||
|
"hooks": "Last hook",
|
||||||
|
"agents": "Agent errors 24h"
|
||||||
|
},
|
||||||
|
"checkTip": {
|
||||||
|
"loop": "How long AgentHub's event loop was held up (p99 over the last minute). Every page, hook and terminal keystroke waits this long.",
|
||||||
|
"cpu": "CPU pressure (Linux PSI, last 10 s): share of time tasks waited for a free CPU — the machine feels slow above a few %.",
|
||||||
|
"cpuLoad": "Load per core (1 min) — this machine has no PSI, so CPU wait is estimated from load.",
|
||||||
|
"memory": "Memory pressure (Linux PSI, last 10 s): share of time tasks stalled reclaiming or swapping memory.",
|
||||||
|
"io": "Disk I/O pressure (Linux PSI, last 10 s): share of time tasks waited on the disk — a slow or busy HDD shows here.",
|
||||||
|
"ram": "Memory in use (total minus available).",
|
||||||
|
"disk": "How full the disk holding AgentHub's database is ({{mount}}).",
|
||||||
|
"heap": "AgentHub's V8 heap against its limit.",
|
||||||
|
"hooks": "Minutes since the last hook event, counted only while an agent is working: silence then means the dashboard stopped seeing it.",
|
||||||
|
"agents": "Agents that ended in the last 24 h with an error (errors / finished; needs at least 5)."
|
||||||
|
},
|
||||||
|
"checkNa": "Not measured here — costs nothing.",
|
||||||
|
"checkThresholds": "Amber from {{warn}}{{unit}}, red from {{bad}}{{unit}}",
|
||||||
|
"checkPenalty": "Costs {{n}} of up to {{max}} points",
|
||||||
|
"hooksMissing": "not installed",
|
||||||
|
"hooksIdle": "idle",
|
||||||
|
"minutesAgo": "{{n}} min ago",
|
||||||
|
"verdict": {
|
||||||
|
"ok": "Healthy",
|
||||||
|
"warn": "Degraded",
|
||||||
|
"bad": "Unhealthy"
|
||||||
|
},
|
||||||
|
"allOk": "Every check is fine",
|
||||||
|
"badCount_one": "{{count}} red",
|
||||||
|
"badCount_other": "{{count}} red",
|
||||||
|
"warnCount_one": "{{count}} amber",
|
||||||
|
"warnCount_other": "{{count}} amber",
|
||||||
|
"lagGroup": "Lag",
|
||||||
|
"systemGroup": "Capacity & signals"
|
||||||
},
|
},
|
||||||
"errorBoundary": {
|
"errorBoundary": {
|
||||||
"title": "Something went wrong",
|
"title": "Something went wrong",
|
||||||
"body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.",
|
"body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"uptimeDays": "{{d}} d {{h}} h",
|
||||||
|
"uptimeHours": "{{h}} h {{m}} min",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
||||||
|
"memory": "Memory",
|
||||||
|
"swap": "Swap",
|
||||||
|
"free": "free",
|
||||||
|
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
||||||
|
"hostGroup": "Host account (AgentHub and other host processes)",
|
||||||
|
"systemGroup": "System (root and other accounts)",
|
||||||
|
"homesBadge": "homes",
|
||||||
|
"historyTitle": "History",
|
||||||
|
"historyHelp": "Kept in memory, one point every {{s}} s, since the server started at {{at}}.",
|
||||||
|
"historyCpu": "CPU (% of the machine)",
|
||||||
|
"historyMemory": "Memory used (%)",
|
||||||
|
"range": "Range",
|
||||||
|
"range1h": "1 h",
|
||||||
|
"range6h": "6 h",
|
||||||
|
"range24h": "24 h",
|
||||||
|
"coreN": "CPU {{n}}",
|
||||||
|
"threads": "{{n}} threads",
|
||||||
|
"physical": "{{n}} cores",
|
||||||
|
"perSecond": "{{v}}/s",
|
||||||
|
"temperatures": "Temperatures",
|
||||||
|
"topProcesses": "Busiest processes",
|
||||||
|
"cpuModel": "CPU",
|
||||||
|
"storageNetwork": "Disks & network",
|
||||||
|
"processCount": "{{n}} processes",
|
||||||
|
"title": "Server",
|
||||||
|
"os": "System",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"load": "Load (1/5/15 min)",
|
||||||
|
"agenthubTip": "pid {{pid}} · Node {{node}}",
|
||||||
|
"sampling": "Sampling…",
|
||||||
|
"sampled": "{{s}} s sample",
|
||||||
|
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
|
||||||
|
"agenthub": "AgentHub",
|
||||||
|
"usersTitle": "Users · CPU / RAM now",
|
||||||
|
"usersCount": "{{n}} users"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,7 @@
|
|||||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||||
"clone.nameLabel": "Folder name",
|
"clone.nameLabel": "Folder name",
|
||||||
"clone.cloning": "Cloning…",
|
"clone.cloning": "Cloning…",
|
||||||
|
"clone.background": "Run in background",
|
||||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||||
"clone.username": "Username",
|
"clone.username": "Username",
|
||||||
@@ -650,9 +651,29 @@
|
|||||||
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
||||||
"fx.myHome": "Home",
|
"fx.myHome": "Home",
|
||||||
"fx.userHomes": "Users' homes",
|
"fx.userHomes": "Users' homes",
|
||||||
|
"fx.users": "Users",
|
||||||
|
"fx.toggleWorkspaces": "Show or hide {{name}}’s workspaces",
|
||||||
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
|
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
|
||||||
"fx.readOnly": "Read-only",
|
"fx.readOnly": "Read-only",
|
||||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
||||||
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
|
"tmuxUpgrade": "Upgrade tmux: {{advice}}",
|
||||||
|
"changes.rescanRepos": "Scan for repositories again",
|
||||||
|
"tree.newGroup": "New group",
|
||||||
|
"tree.groupDefaultName": "New group",
|
||||||
|
"tree.ungrouped": "Ungrouped",
|
||||||
|
"tree.groupToggle": "Show or hide the {{name}} group",
|
||||||
|
"tree.groupMenu": "Group options",
|
||||||
|
"tree.groupRename": "Rename group",
|
||||||
|
"tree.groupDelete": "Delete group",
|
||||||
|
"tree.groupMoveUp": "Move group up",
|
||||||
|
"tree.groupMoveDown": "Move group down",
|
||||||
|
"tree.groupDeleteConfirm": "Delete the group \"{{name}}\"? Its workspaces stay, ungrouped.",
|
||||||
|
"tree.groupNameLabel": "Group name",
|
||||||
|
"menu.moveToGroup": "Move to group",
|
||||||
|
"menu.removeFromGroup": "Remove from group",
|
||||||
|
"menu.newGroupWith": "New group…",
|
||||||
|
"tree.groupAddWorkspace": "New workspace in this group…",
|
||||||
|
"tree.groupFoldAll": "Fold all groups",
|
||||||
|
"tree.groupUnfoldAll": "Unfold all groups"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||||
"quotaActive": "in use",
|
"quotaActive": "in use",
|
||||||
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
|
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
|
||||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
"quotaProfileExpired": "Sign-in expired: use Check quota in Settings → AI tools → Profiles to refresh it",
|
||||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||||
"quotaSignInExpired": "Sign-in expired",
|
"quotaSignInExpired": "Sign-in expired",
|
||||||
"quotaWindow": {
|
"quotaWindow": {
|
||||||
@@ -88,5 +88,6 @@
|
|||||||
"now": "now",
|
"now": "now",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"admin": "Administration",
|
"admin": "Administration",
|
||||||
"files": "Files"
|
"files": "Files",
|
||||||
|
"snapshot": "Snapshot"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"failed": "Could not attach the file",
|
"failed": "Could not attach the file",
|
||||||
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
||||||
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
||||||
|
"tooBigLocalPaste": "File too large to upload (max {{mb}} MB) — copy it in your file manager and paste it here instead (Ctrl+V): a pasted file goes in by its own path",
|
||||||
"remove": "Remove attachment",
|
"remove": "Remove attachment",
|
||||||
"view": "View image"
|
"view": "View image"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
"tableAgents": "Agents",
|
"tableAgents": "Agents",
|
||||||
"tableCost": "Cost",
|
"tableCost": "Cost",
|
||||||
"tableDirectory": "Directory",
|
"tableDirectory": "Directory",
|
||||||
|
"tableUser": "User",
|
||||||
|
"tableTool": "AI Tool",
|
||||||
|
"tableActions": "Actions",
|
||||||
|
"noOwner": "No owner",
|
||||||
|
"moreActions": "More actions",
|
||||||
|
"viewDetails": "View details",
|
||||||
"defaultName": "Session ",
|
"defaultName": "Session ",
|
||||||
"detail": {
|
"detail": {
|
||||||
"waitingBanner": {
|
"waitingBanner": {
|
||||||
|
|||||||
@@ -77,21 +77,13 @@
|
|||||||
"run_done_other": "{{count}} runs finished 🐾"
|
"run_done_other": "{{count}} runs finished 🐾"
|
||||||
},
|
},
|
||||||
"greet": {
|
"greet": {
|
||||||
"morning": [
|
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
|
||||||
"good morning! ☀️",
|
|
||||||
"morning! coffee first? ☕",
|
|
||||||
"rise and shine 🌤️"
|
|
||||||
],
|
|
||||||
"afternoon": [
|
"afternoon": [
|
||||||
"good afternoon! 🐾",
|
"good afternoon! 🐾",
|
||||||
"hey, welcome back 😺",
|
"hey, welcome back 😺",
|
||||||
"afternoon! ready when you are ✨"
|
"afternoon! ready when you are ✨"
|
||||||
],
|
],
|
||||||
"evening": [
|
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
|
||||||
"good evening! 🌆",
|
|
||||||
"evening shift? I'm in 😼",
|
|
||||||
"hey there, night owl-to-be 🌙"
|
|
||||||
],
|
|
||||||
"night": [
|
"night": [
|
||||||
"burning the midnight oil? 🌙",
|
"burning the midnight oil? 🌙",
|
||||||
"late one, huh? I'll keep watch 🦉",
|
"late one, huh? I'll keep watch 🦉",
|
||||||
@@ -114,11 +106,7 @@
|
|||||||
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
||||||
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
||||||
"hello": "hi there! 😺 {{summary}}.",
|
"hello": "hi there! 😺 {{summary}}.",
|
||||||
"thanks": [
|
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
|
||||||
"anytime! 💜",
|
|
||||||
"happy to help 😸",
|
|
||||||
"you got it 🐾"
|
|
||||||
],
|
|
||||||
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
||||||
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||||
"errorsNone": "no errors - all clean 🐾",
|
"errorsNone": "no errors - all clean 🐾",
|
||||||
@@ -175,41 +163,17 @@
|
|||||||
"can't reach the server 📡",
|
"can't reach the server 📡",
|
||||||
"no signal - taking a nap 💤"
|
"no signal - taking a nap 💤"
|
||||||
],
|
],
|
||||||
"worried": [
|
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||||
"that didn't look right 😟",
|
|
||||||
"keeping an eye out 👀",
|
|
||||||
"hmm, something's off 🫣"
|
|
||||||
],
|
|
||||||
"stuck": [
|
"stuck": [
|
||||||
"a session's been quiet a while… 🤔",
|
"a session's been quiet a while… 🤔",
|
||||||
"is something stuck? ⏳",
|
"is something stuck? ⏳",
|
||||||
"still chewing on it… 😾"
|
"still chewing on it… 😾"
|
||||||
],
|
],
|
||||||
"happy": [
|
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||||
"great run! 😻",
|
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||||
"love a tidy finish ✨",
|
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||||
"purrfect 💜"
|
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||||
],
|
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
||||||
"thinking": [
|
|
||||||
"hmm, let me look… 🤔",
|
|
||||||
"sniffing around… 🐾",
|
|
||||||
"one sec, checking 🔍"
|
|
||||||
],
|
|
||||||
"watching": [
|
|
||||||
"on the prowl 👀",
|
|
||||||
"watching your sessions 😼",
|
|
||||||
"eyes peeled 🐾"
|
|
||||||
],
|
|
||||||
"sleeping": [
|
|
||||||
"zzz… 💤",
|
|
||||||
"wake me if something happens 😴",
|
|
||||||
"curled up, all calm 🐈"
|
|
||||||
],
|
|
||||||
"idle": [
|
|
||||||
"all quiet 😺",
|
|
||||||
"ready when you are 🐾",
|
|
||||||
"just vibing ✨"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"label": "Chat with {{name}}",
|
"label": "Chat with {{name}}",
|
||||||
@@ -795,6 +759,29 @@
|
|||||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||||
"add": "Add profile",
|
"add": "Add profile",
|
||||||
"config9router": "Config 9router",
|
"config9router": "Config 9router",
|
||||||
|
"update9router": "Update 9router",
|
||||||
|
"update9routerAvailable": "A newer 9router is available",
|
||||||
|
"update9routerCurrent": "Installed",
|
||||||
|
"update9routerLatest": "Latest",
|
||||||
|
"update9routerState": "State",
|
||||||
|
"update9routerRunning": "Running",
|
||||||
|
"update9routerRunningUnit": "Running (9router.service)",
|
||||||
|
"update9routerStopped": "Not running",
|
||||||
|
"update9routerExplain": "Every session using 9router is disconnected while it updates: 9router is shut down, updated with npm, then started again hidden (tray, no browser).",
|
||||||
|
"update9routerExplainStopped": "9router is not running: it is updated with npm and left off.",
|
||||||
|
"update9routerStep": {
|
||||||
|
"shutdown": "Disconnect and shut down 9router",
|
||||||
|
"update": "Run the update (npm install -g 9router@latest)",
|
||||||
|
"relaunch": "Start 9router again, hidden"
|
||||||
|
},
|
||||||
|
"update9routerDone": "9router {{version}} is running.",
|
||||||
|
"update9routerDoneStopped": "9router {{version}} is installed and left off, as it was.",
|
||||||
|
"update9routerFailed": "The update did not finish — see the steps and the log.",
|
||||||
|
"update9routerLog": "npm output",
|
||||||
|
"update9routerUpToDate": "9router is already the latest version.",
|
||||||
|
"update9routerCopy": "Copy command",
|
||||||
|
"update9routerClose": "Close",
|
||||||
|
"update9routerStart": "Update now",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||||
@@ -920,7 +907,6 @@
|
|||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
"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: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
||||||
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
|
|
||||||
"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.",
|
||||||
@@ -930,7 +916,6 @@
|
|||||||
"awsRegion": "Region",
|
"awsRegion": "Region",
|
||||||
"awsSave": "Save",
|
"awsSave": "Save",
|
||||||
"awsSaved": "SSH key ID saved for {{region}}",
|
"awsSaved": "SSH key ID saved for {{region}}",
|
||||||
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
|
|
||||||
"otherTitle": "Other SSH hosts (advanced)",
|
"otherTitle": "Other SSH hosts (advanced)",
|
||||||
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
||||||
"otherHost": "Host",
|
"otherHost": "Host",
|
||||||
@@ -944,16 +929,14 @@
|
|||||||
"alias": "Account name",
|
"alias": "Account name",
|
||||||
"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? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
"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 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||||
"moreKeysTitle": "More SSH keys",
|
|
||||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
|
||||||
"newKey": "New key",
|
|
||||||
"keyName": "Key name",
|
|
||||||
"keyNamePlaceholder": "e.g. aws-prod",
|
|
||||||
"keyType": "Key type",
|
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "GitHub · GitLab",
|
||||||
"tabMore": "More keys",
|
"tabOther": "Other hosts",
|
||||||
"tabOther": "Other hosts"
|
"addKey": "Add another key",
|
||||||
|
"namedGitSub": "ED25519 · for one more GitHub/GitLab 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.",
|
||||||
|
"removeKeyNamed": "Delete key {{name}}"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
@@ -1073,6 +1056,10 @@
|
|||||||
"title": "Claude Code",
|
"title": "Claude Code",
|
||||||
"description": "Claude Code's own configuration, and what AgentHub reads from it."
|
"description": "Claude Code's own configuration, and what AgentHub reads from it."
|
||||||
},
|
},
|
||||||
|
"claudeCodeOwn": {
|
||||||
|
"title": "Your Claude Code config",
|
||||||
|
"description": "The skills, agents, plugins, MCP servers and settings your own sessions use."
|
||||||
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"title": "Codex",
|
"title": "Codex",
|
||||||
"description": "Codex's own configuration, and what AgentHub reads from it."
|
"description": "Codex's own configuration, and what AgentHub reads from it."
|
||||||
@@ -1113,6 +1100,12 @@
|
|||||||
"noMatch": "No setting matches “{{query}}”.",
|
"noMatch": "No setting matches “{{query}}”.",
|
||||||
"results": "Search results"
|
"results": "Search results"
|
||||||
},
|
},
|
||||||
|
"changesPanel": {
|
||||||
|
"title": "Changes panel",
|
||||||
|
"description": "For a workspace folder that holds several git repositories (each with its own .git). Saved to your account.",
|
||||||
|
"innerRepos": "Show the repositories inside the workspace folder",
|
||||||
|
"innerReposDesc": "Each repository found inside the folder (up to two levels down) gets its own section in the Changes panel. Off: only the folder's own git is shown."
|
||||||
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Worktree suggestions",
|
"title": "Worktree suggestions",
|
||||||
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
|
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
|
||||||
@@ -1172,6 +1165,10 @@
|
|||||||
"18": "Larger"
|
"18": "Larger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nativePaste": {
|
||||||
|
"label": "Paste images natively",
|
||||||
|
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Needs wl-paste or xclip on Linux; without them the image is uploaded instead. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
|
||||||
|
},
|
||||||
"terminalFont": {
|
"terminalFont": {
|
||||||
"title": "Terminal font",
|
"title": "Terminal font",
|
||||||
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
||||||
@@ -1357,7 +1354,8 @@
|
|||||||
"tooBig": "The picture must be at most 1 MB",
|
"tooBig": "The picture must be at most 1 MB",
|
||||||
"fromUrl": "Use a picture from a link",
|
"fromUrl": "Use a picture from a link",
|
||||||
"urlPlaceholder": "https://… image link",
|
"urlPlaceholder": "https://… image link",
|
||||||
"urlApply": "Use"
|
"urlApply": "Use",
|
||||||
|
"linkUnreachable": "That link did not load as an image in your browser — it may need a login (a Mattermost or other chat picture does). Download the picture and upload it instead."
|
||||||
},
|
},
|
||||||
"loadFailed": "Could not load your account: {{message}}",
|
"loadFailed": "Could not load your account: {{message}}",
|
||||||
"memberSince": "Member since",
|
"memberSince": "Member since",
|
||||||
@@ -1385,5 +1383,46 @@
|
|||||||
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
|
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
|
||||||
"signedOut_one": "Signed out {{count}} other device",
|
"signedOut_one": "Signed out {{count}} other device",
|
||||||
"signedOut_other": "Signed out {{count}} other devices"
|
"signedOut_other": "Signed out {{count}} other devices"
|
||||||
|
},
|
||||||
|
"toolProfiles": {
|
||||||
|
"title": "Profiles",
|
||||||
|
"description": "Every AI tool's saved logins in one place. Switching swaps only the login; config, skills, sessions and history stay as they are.",
|
||||||
|
"poolCount_one": "The account pool holds {{count}} account for this tool.",
|
||||||
|
"poolCount_other": "The account pool holds {{count}} accounts for this tool.",
|
||||||
|
"openPool": "Open the account pool",
|
||||||
|
"moveToPool": "Move to pool",
|
||||||
|
"moveToPoolHint": "Administration → Users → Account pool, with this profile chosen. The pool becomes the login's only holder and the profile is removed.",
|
||||||
|
"saveButton": "Save this login as a profile",
|
||||||
|
"noActive": "{{tool}} holds no login on this machine.",
|
||||||
|
"noProfiles": "Could not load the profiles.",
|
||||||
|
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||||
|
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||||
|
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||||
|
"subtitle": {
|
||||||
|
"codex": "A profile is one Codex login (a ChatGPT account or an API key): the whole auth.json. config.toml, sessions, skills and history stay where they are.",
|
||||||
|
"omp": "A profile is the full set of logins Oh My Pi stores (every provider), read with `omp auth export` and put back with `omp auth import`. Settings, models and sessions stay where they are. Running omp sessions pick up the switch on their own."
|
||||||
|
},
|
||||||
|
"unsupported": {
|
||||||
|
"antigravity": "Antigravity keeps its login in the system keyring, which AgentHub does not read or write, so it cannot be switched from here. Sign in or out inside agy itself."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"chatgpt": "ChatGPT login",
|
||||||
|
"apikey": "API key",
|
||||||
|
"credentials": "Logins"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"poolAccount": {
|
||||||
|
"title": "Pool account",
|
||||||
|
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
|
||||||
|
"automatic": "Automatic",
|
||||||
|
"select": "{{tool}} pool account",
|
||||||
|
"count_one": "{{count}} account you may use",
|
||||||
|
"count_other": "{{count}} accounts you may use",
|
||||||
|
"pinned": "An admin pinned you to {{account}}",
|
||||||
|
"expired": "expired",
|
||||||
|
"full": "quota full",
|
||||||
|
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||||
|
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||||
|
"nextSession": "{{account}} is used from your next session"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,10 @@
|
|||||||
"autoFix": "Auto fix",
|
"autoFix": "Auto fix",
|
||||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||||
"autoFixWorkspace": "AgentHub update",
|
"autoFixWorkspace": "AgentHub update",
|
||||||
"autoFixError": "Could not start the Auto fix session"
|
"autoFixError": "Could not start the Auto fix session",
|
||||||
|
"chipTitle": "Updating to {{version}}…",
|
||||||
|
"chipHint": "The update runs in the background — click to show it",
|
||||||
|
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||||
|
"pluginStep.plugin_update": "Step {{index}}/{{total}}: downloading the new version",
|
||||||
|
"pluginStep.bootstrap": "Step {{index}}/{{total}}: installing it and restarting the dashboard"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"role": "Role",
|
"role": "Role",
|
||||||
"workspaces": "Workspaces",
|
"workspaces": "Workspaces",
|
||||||
"sessions": "Sessions",
|
"sessions": "Sessions",
|
||||||
|
"today": "Today",
|
||||||
"last7d": "7 days",
|
"last7d": "7 days",
|
||||||
"last30d": "30 days",
|
"last30d": "30 days",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
@@ -132,12 +133,10 @@
|
|||||||
},
|
},
|
||||||
"pool": {
|
"pool": {
|
||||||
"title": "Account pool",
|
"title": "Account pool",
|
||||||
"description": "Claude and Codex accounts every user's new sessions run on — the least-used one is picked, and a login is written into the user's home by the server, so nobody signs in per user.",
|
"description": "Each pool holds accounts of every AI tool (Claude Code, Codex, Cursor) and the members who run on them. A member's new session takes the least-used account of their pool, so nobody signs in per user.",
|
||||||
"refresh": "Re-read quotas",
|
"refresh": "Re-read quotas",
|
||||||
"add": "Add account",
|
"add": "Add account",
|
||||||
"empty": "No accounts yet. Until you add one, each user's session needs that user's own sign-in.",
|
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"tool": "Tool",
|
|
||||||
"livePanes": "Live",
|
"livePanes": "Live",
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
"spend": "5 hours",
|
"spend": "5 hours",
|
||||||
@@ -146,12 +145,6 @@
|
|||||||
"added": "added {{at}}",
|
"added": "added {{at}}",
|
||||||
"expired": "expired",
|
"expired": "expired",
|
||||||
"full": "full",
|
"full": "full",
|
||||||
"kind": {
|
|
||||||
"token": "Claude · setup-token",
|
|
||||||
"login": "Claude · login",
|
|
||||||
"apikey": "Codex · API key",
|
|
||||||
"chatgpt": "Codex · ChatGPT"
|
|
||||||
},
|
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
||||||
"remove": "Remove {{name}}",
|
"remove": "Remove {{name}}",
|
||||||
@@ -173,35 +166,40 @@
|
|||||||
"ENOTFOUND": "That saved login no longer exists.",
|
"ENOTFOUND": "That saved login no longer exists.",
|
||||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||||
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
||||||
"ENOUSER": "That user no longer exists."
|
"ENOUSER": "That user no longer exists.",
|
||||||
|
"ENOGROUP": "That group no longer exists."
|
||||||
},
|
},
|
||||||
"source": "From",
|
"source": "From",
|
||||||
"sources": {
|
"sources": {
|
||||||
"profile": "A saved login (Switch account profiles)",
|
"profile": "A saved profile (Settings → AI tools → Profiles)",
|
||||||
"credentials": "Paste .credentials.json / Keychain",
|
"credentials": "Paste .credentials.json / Keychain",
|
||||||
"token": "claude setup-token"
|
"token": "claude setup-token"
|
||||||
},
|
},
|
||||||
"sourceHint": {
|
"sourceHint": {
|
||||||
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||||
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable."
|
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable.",
|
||||||
|
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account.",
|
||||||
|
"cursor": "A Cursor API key from the Cursor dashboard (Integrations → API keys)."
|
||||||
},
|
},
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"profileHint": "Saved in Settings → Claude config → Switch account. The one this server's own Claude Code is logged into can be moved too: the server keeps running on it and refreshes it for everyone.",
|
"profileHint": "Saved in Settings → AI tools → Profiles. The one this server's own CLI is logged into can be moved too: the server keeps running on it.",
|
||||||
"chooseProfile": "Choose a saved login…",
|
"chooseProfile": "Choose a saved login…",
|
||||||
"noProfiles": "No saved logins — save one in Settings → Claude config → Switch account",
|
"noProfiles": "No saved profiles — save one in Settings → AI tools → Profiles",
|
||||||
"loadingProfiles": "Loading…",
|
"loadingProfiles": "Loading…",
|
||||||
"activeHere": "in use on this server — keeps working",
|
"activeHere": "in use on this server — keeps working",
|
||||||
"nameFromProfile": "Leave blank to use the profile's name",
|
"nameFromProfile": "Leave blank to use the profile's name",
|
||||||
"secret": {
|
"secret": {
|
||||||
"credentials": "Credentials JSON",
|
"credentials": "Credentials JSON",
|
||||||
"token": "Token",
|
"token": "Token",
|
||||||
"codex": "API key or auth.json"
|
"codex": "API key or auth.json",
|
||||||
|
"cursor": "Cursor API key"
|
||||||
},
|
},
|
||||||
"secretHint": {
|
"secretHint": {
|
||||||
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
||||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account.",
|
||||||
|
"cursor": "Create one in the Cursor dashboard under Integrations → API keys."
|
||||||
},
|
},
|
||||||
"refreshFailed": "refresh failed",
|
"refreshFailed": "refresh failed",
|
||||||
"pinned": "Used by: {{users}}",
|
"pinned": "Used by: {{users}}",
|
||||||
@@ -216,7 +214,121 @@
|
|||||||
"users": "Users on this account",
|
"users": "Users on this account",
|
||||||
"noUsers": "No users yet.",
|
"noUsers": "No users yet.",
|
||||||
"onAccount": "now on {{account}}",
|
"onAccount": "now on {{account}}",
|
||||||
"editDone": "{{name}} saved"
|
"editDone": "{{name}} saved",
|
||||||
|
"profilesLink": "Profiles in Settings",
|
||||||
|
"addFor": "Add {{tool}} account",
|
||||||
|
"type": "Type",
|
||||||
|
"about": {
|
||||||
|
"claude": "New Claude Code sessions run on these accounts. A setup-token goes in as an environment variable; a real login is written into each user's home by the server and refreshed by it.",
|
||||||
|
"codex": "New Codex sessions (not on an AI Proxy) run on these accounts. An OpenAI API key goes in as an environment variable; a ChatGPT login is written into each user's own Codex home.",
|
||||||
|
"cursor": "New Cursor CLI sessions run on these Cursor API keys, given to the session as CURSOR_API_KEY — it outranks the user's own `cursor-agent login` for that session only. Cursor reports no usage or quota here, so the account with the fewest live sessions is picked."
|
||||||
|
},
|
||||||
|
"notInstalled": "{{tool}} is not installed on this server yet.",
|
||||||
|
"emptyFor": "No {{tool}} accounts yet. Until you add one, each user's {{tool}} session needs that user's own sign-in.",
|
||||||
|
"kindShort": {
|
||||||
|
"token": "setup-token",
|
||||||
|
"login": "Login",
|
||||||
|
"apikey": "API key",
|
||||||
|
"chatgpt": "ChatGPT login"
|
||||||
|
},
|
||||||
|
"omp": {
|
||||||
|
"badgeOn": "shared",
|
||||||
|
"about": "Oh My Pi has no account pool: it shares this server's own omp login instead. Sign the host account in with `omp` (then /login), turn sharing on, and every user's omp session runs on it through omp's own gateway — with a key that works only while its pane runs.",
|
||||||
|
"off": "Sharing is off: each user's omp session uses that user's own login.",
|
||||||
|
"unavailable": "Oh My Pi has no account pool. Its login can be shared only on a server where every user has their own Linux account (server mode with per-user accounts); here each user's omp session uses that user's own login."
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"title": "{{tool}} has no account pool",
|
||||||
|
"reason": {
|
||||||
|
"antigravity": "Antigravity signs in with a Google account and keeps that login in the system keyring; it takes no key AgentHub could hand a session. Each user signs in to agy themselves.",
|
||||||
|
"default": "AgentHub cannot hand {{tool}} a shared login, so each user signs in to it themselves."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liveCount_one": "{{count}} live",
|
||||||
|
"liveCount_other": "{{count}} live",
|
||||||
|
"savedProfiles": {
|
||||||
|
"title": "Saved profiles you can share",
|
||||||
|
"empty": "No saved profiles for this tool. Save one in Settings → AI tools → Profiles, then share it here.",
|
||||||
|
"activeHere": "in use on this server",
|
||||||
|
"share": "Share to pool",
|
||||||
|
"shareNamed": "Share {{name}} to the pool"
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"title": "Pools",
|
||||||
|
"description": "Members in no pool, and accounts in no pool, make up the shared pool.",
|
||||||
|
"new": "New pool",
|
||||||
|
"empty": "No groups yet — every user shares every account.",
|
||||||
|
"members_one": "{{count}} member",
|
||||||
|
"members_other": "{{count}} members",
|
||||||
|
"edit": "Edit group {{name}}",
|
||||||
|
"delete": "Delete pool {{name}}",
|
||||||
|
"deleteHint": "Its members and accounts go back to the shared pool. No account is removed.",
|
||||||
|
"noAccounts": "none — members sign in themselves",
|
||||||
|
"saved": "Group {{name}} saved",
|
||||||
|
"deleted": "Pool {{name}} deleted",
|
||||||
|
"hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.",
|
||||||
|
"name": "Pool name",
|
||||||
|
"memberList": "Members",
|
||||||
|
"inGroup": "now in {{group}}",
|
||||||
|
"accountList": "Accounts",
|
||||||
|
"noneInPool": "No accounts of this tool in the pool.",
|
||||||
|
"shared": "Shared pool"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"5h": "5 hours",
|
||||||
|
"wk": "Week"
|
||||||
|
},
|
||||||
|
"resetsIn": "resets in {{time}} · {{at}}",
|
||||||
|
"members": {
|
||||||
|
"title": "Members",
|
||||||
|
"filter": "Show members",
|
||||||
|
"all": "All",
|
||||||
|
"noGroup": "No pool",
|
||||||
|
"search": "Search members",
|
||||||
|
"tickShown": "Tick all {{count}}",
|
||||||
|
"noneShown": "No member matches."
|
||||||
|
},
|
||||||
|
"board": {
|
||||||
|
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Nothing is saved until Save.",
|
||||||
|
"sharedHint": "Members and accounts in no pool",
|
||||||
|
"toShared": "Send {{name}} back to the shared pool",
|
||||||
|
"removeAccount": "Send {{name}} back to the shared pool",
|
||||||
|
"moveHere_one": "Move {{count}} here",
|
||||||
|
"moveHere_other": "Move {{count}} here",
|
||||||
|
"noMembers": "No members",
|
||||||
|
"noSharedAccounts": "No account is left for the shared pool.",
|
||||||
|
"dropMembers": "Drop members here",
|
||||||
|
"untitled": "New pool",
|
||||||
|
"new": "not saved yet",
|
||||||
|
"pick": "Pick members of {{name}}",
|
||||||
|
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Applied on the board — Save sends it.",
|
||||||
|
"apply": "Apply",
|
||||||
|
"addAccount": "Move an account into {{name}}",
|
||||||
|
"addAccountOption": "+ Move an account or saved profile here…",
|
||||||
|
"profilesOf": "Saved {{tool}} profiles — moved into the pool on Save",
|
||||||
|
"profilePending": "A saved profile: it is moved into the pool when you save",
|
||||||
|
"pending_one": "{{count}} unsaved change",
|
||||||
|
"pending_other": "{{count}} unsaved changes",
|
||||||
|
"needsName": "Every pool needs a name.",
|
||||||
|
"discard": "Discard",
|
||||||
|
"saveTitle": "Save the pools",
|
||||||
|
"saveHint": "Type your own password once to apply every change below.",
|
||||||
|
"summaryProfile": "Move the saved profile {{name}} into the pool",
|
||||||
|
"summaryNew": "Create {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||||
|
"summaryGroup": "Set {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||||
|
"saved": "Pools saved",
|
||||||
|
"dropAccounts": "Drag accounts here",
|
||||||
|
"noAccounts": "No accounts",
|
||||||
|
"accountsCount_one": "{{count}} account",
|
||||||
|
"accountsCount_other": "{{count}} accounts",
|
||||||
|
"mergeHint": "Drop to merge {{from}} into {{into}}",
|
||||||
|
"merged": "{{from}} merged into {{into}} — Save to apply",
|
||||||
|
"summaryMerge": "Merge {{from}} into {{into}}: pool {{from}} is deleted",
|
||||||
|
"newDrop": "Drop here to split it into a pool of its own",
|
||||||
|
"newHint": "Or drag an account or a member here to split it off"
|
||||||
|
},
|
||||||
|
"addAccount": "Add account",
|
||||||
|
"otherTools": "Other AI tools"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Delete user",
|
"button": "Delete user",
|
||||||
@@ -253,7 +365,9 @@
|
|||||||
"doneIdle": "Restarted: {{names}}",
|
"doneIdle": "Restarted: {{names}}",
|
||||||
"errors": {
|
"errors": {
|
||||||
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
|
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
|
||||||
}
|
},
|
||||||
|
"sessionsCount_one": "{{count}} session",
|
||||||
|
"sessionsCount_other": "{{count}} sessions"
|
||||||
},
|
},
|
||||||
"rowMenu": {
|
"rowMenu": {
|
||||||
"openHome": "Open home in Files",
|
"openHome": "Open home in Files",
|
||||||
@@ -269,5 +383,34 @@
|
|||||||
"digit": "A digit",
|
"digit": "A digit",
|
||||||
"symbol": "A symbol (e.g. ! - _ @)",
|
"symbol": "A symbol (e.g. ! - _ @)",
|
||||||
"username": "Does not contain the username"
|
"username": "Does not contain the username"
|
||||||
}
|
},
|
||||||
|
"detail": {
|
||||||
|
"open": "Open {{name}}'s details",
|
||||||
|
"lastLogin": "Last sign-in",
|
||||||
|
"lastSession": "Last activity",
|
||||||
|
"never": "never",
|
||||||
|
"spendDaily": "Spend · last 30 days",
|
||||||
|
"noUsage": "No spend in the last 30 days.",
|
||||||
|
"pool": "Account pool & quota",
|
||||||
|
"poolPinned": "pinned to {{account}}",
|
||||||
|
"poolChosen": "chose {{account}}",
|
||||||
|
"poolGroup": "group {{name}}",
|
||||||
|
"poolShared": "shared pool",
|
||||||
|
"poolNone": "No account in this tool's pool.",
|
||||||
|
"poolNotAllowed": "no longer allowed",
|
||||||
|
"poolTotal7d": "Account total 7 days: {{cost}}",
|
||||||
|
"noPanes": "Nothing running.",
|
||||||
|
"recentTitle": "Recent sessions",
|
||||||
|
"failed": "Could not load this user",
|
||||||
|
"activeLabel": "Running now",
|
||||||
|
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
||||||
|
"nowTitle": "Running now",
|
||||||
|
"poolTheirs7d": "theirs 7 d {{cost}}"
|
||||||
|
},
|
||||||
|
"activity": "Activity",
|
||||||
|
"spend": "Spend today",
|
||||||
|
"spendSub": "7 d {{week}} · 30 d {{month}}",
|
||||||
|
"sessionsN": "{{n}} sessions",
|
||||||
|
"workspacesN": "{{n}} workspaces",
|
||||||
|
"tmuxCol": "tmux"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "Quản trị",
|
"title": "Quản trị",
|
||||||
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
|
"description": "Tài khoản và máy chủ AgentHub đang chạy."
|
||||||
"tabs": {
|
|
||||||
"users": "Người dùng",
|
|
||||||
"server": "Máy chủ"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"title": "Máy chủ",
|
|
||||||
"uptime": "đã chạy {{v}}",
|
|
||||||
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
|
||||||
"uptimeHours": "{{h}} giờ {{m}} phút",
|
|
||||||
"cpu": "CPU",
|
|
||||||
"cores": "{{n}} lõi · tải {{load}}",
|
|
||||||
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
|
||||||
"memory": "Bộ nhớ",
|
|
||||||
"swap": "Swap",
|
|
||||||
"homesDisk": "Ổ chứa thư mục người dùng",
|
|
||||||
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
|
|
||||||
"free": "trống",
|
|
||||||
"usageTitle": "Mức sử dụng theo người dùng",
|
|
||||||
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
|
|
||||||
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
|
||||||
"who": "Người dùng",
|
|
||||||
"processes": "Tiến trình",
|
|
||||||
"home": "Thư mục home",
|
|
||||||
"sampling": "Đang lấy mẫu…",
|
|
||||||
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
|
||||||
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
|
||||||
"disks": "Ổ đĩa",
|
|
||||||
"mount": "Điểm gắn",
|
|
||||||
"device": "Thiết bị",
|
|
||||||
"used": "Đã dùng",
|
|
||||||
"homesBadge": "home",
|
|
||||||
"gpus": "GPU"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,5 +183,43 @@
|
|||||||
"stat.updated": "Cập nhật",
|
"stat.updated": "Cập nhật",
|
||||||
"detail.runAs": "Gọi bằng",
|
"detail.runAs": "Gọi bằng",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Thêm lúc {{date}}"
|
"detail.added": "Thêm lúc {{date}}",
|
||||||
|
"tab.unowned": "Vô chủ",
|
||||||
|
"tab.suggested": "Có phải của bạn?",
|
||||||
|
"auto": "Tự động",
|
||||||
|
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
|
||||||
|
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
|
||||||
|
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
|
||||||
|
"unowned.mine": "Của tôi",
|
||||||
|
"unowned.takeIt": "Nhận về tôi",
|
||||||
|
"unowned.notMine": "Không phải của tôi",
|
||||||
|
"unowned.assign": "Gán cho…",
|
||||||
|
"unowned.claimed": "Đã nhận về thư viện của bạn",
|
||||||
|
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
|
||||||
|
"unowned.assigned": "Đã gán chủ",
|
||||||
|
"unowned.empty": "Không có mục nào chưa có chủ.",
|
||||||
|
"private": "Riêng tư",
|
||||||
|
"privateHint": "Chưa chia sẻ: chỉ bạn (và admin) thấy. Bấm Chia sẻ để đưa lên Hub.",
|
||||||
|
"showMore": "Xem thêm {{count}}",
|
||||||
|
"view.label": "Cách hiển thị",
|
||||||
|
"view.list": "Dạng danh sách",
|
||||||
|
"view.grid": "Dạng lưới",
|
||||||
|
"pack.title": "Bộ {{prefix}}-*",
|
||||||
|
"pack.count": "{{count}} skill",
|
||||||
|
"pack.installAll": "Cài tất cả",
|
||||||
|
"pack.installRest": "Cài {{count}} còn lại",
|
||||||
|
"pack.installTitle": "Cài {{count}} skill của bộ {{prefix}}-*?",
|
||||||
|
"pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:",
|
||||||
|
"pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}",
|
||||||
|
"section.unshared": "Chủ chưa chia sẻ",
|
||||||
|
"section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy",
|
||||||
|
"update": "Cập nhật",
|
||||||
|
"updateHint": "Người tạo đã sửa skill này từ khi bạn cài",
|
||||||
|
"updateTitle": "Cập nhật “{{title}}”?",
|
||||||
|
"updateBody": "Bản {{name}} của 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/, không bị xóa.",
|
||||||
|
"updated": "Đã cập nhật {{name}}",
|
||||||
|
"pack.updateN": "Cập nhật {{count}}",
|
||||||
|
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
||||||
|
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
||||||
|
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "Trình khám phá cấu hình Claude",
|
"title": "Trình khám phá cấu hình Claude",
|
||||||
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
|
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
|
||||||
|
"own": {
|
||||||
|
"label": "Cấu hình Claude Code của bạn — {{home}}",
|
||||||
|
"subtitle": "Chỉ thiết lập của tài khoản bạn: mọi thay đổi ở đây chạy dưới tài khoản của bạn và chỉ áp dụng cho các phiên của chính bạn."
|
||||||
|
},
|
||||||
"refresh": "Làm mới",
|
"refresh": "Làm mới",
|
||||||
"refreshing": "Đang làm mới…",
|
"refreshing": "Đang làm mới…",
|
||||||
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
|
"claudeHome": ".claude của bạn",
|
||||||
"projectClaudeDir": "Dự án .claude",
|
"projectClaudeDir": "Dự án .claude",
|
||||||
"projectRoot": "Thư mục dự án",
|
"projectRoot": "Thư mục dự án",
|
||||||
"claudeJson": "claude.json",
|
"claudeJson": "claude.json",
|
||||||
|
|||||||
@@ -30,14 +30,6 @@
|
|||||||
"since": "đang chờ {{ago}}"
|
"since": "đang chờ {{ago}}"
|
||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
"runtime": "Môi trường chạy",
|
|
||||||
"cores_one": "{{count}} lõi",
|
|
||||||
"cores_other": "{{count}} lõi",
|
|
||||||
"uptime": "Thời gian chạy",
|
|
||||||
"cpu": "CPU (1/5/15 phút)",
|
|
||||||
"nodeRss": "Bộ nhớ Node (RSS)",
|
|
||||||
"hostMemory": "Bộ nhớ máy",
|
|
||||||
"hostMemoryTip": "Bộ nhớ máy: đã dùng {{pct}}%\n{{used}} / {{total}}",
|
|
||||||
"heap": "Heap V8",
|
"heap": "Heap V8",
|
||||||
"heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}",
|
"heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}",
|
||||||
"storage": "Lưu trữ",
|
"storage": "Lưu trữ",
|
||||||
@@ -50,12 +42,7 @@
|
|||||||
"events": "Sự kiện",
|
"events": "Sự kiện",
|
||||||
"score": "Điểm sức khỏe",
|
"score": "Điểm sức khỏe",
|
||||||
"formula": "Công thức",
|
"formula": "Công thức",
|
||||||
"formulaTip": "Công thức chỉ số tổng hợp:\n0.4 × Tỉ lệ thành công ({{success}}%)\n+ 0.25 × Trúng cache ({{cache}}%)\n+ 0.25 × (100 − Tỉ lệ lỗi) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
|
"cacheTip": "Tỉ lệ trúng cache transcript: {{pct}}% (chỉ để xem, không tính điểm — cache trống lại mỗi lần server khởi động)",
|
||||||
"scoreTip": "Điểm: {{score}} / 100\n\n• Tỉ lệ thành công (40%): {{success}}%\n• Trúng cache (25%): {{cache}}%\n• Tránh lỗi (25%): {{noError}}%\n• Sức khỏe bộ nhớ (10%): {{noHeap}}%",
|
|
||||||
"cache": "Cache",
|
|
||||||
"cacheTip": "Tỉ lệ trúng cache: {{pct}}%\nTrúng: {{hits}}\nTrượt: {{misses}}",
|
|
||||||
"errors": "Lỗi",
|
|
||||||
"errorsTip": "Tỉ lệ lỗi: {{pct}}%\n<5% = tốt, 5–15% = cảnh báo, >15% = nghiêm trọng",
|
|
||||||
"compact": "Nén",
|
"compact": "Nén",
|
||||||
"compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.",
|
"compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.",
|
||||||
"saved": "Tiết kiệm",
|
"saved": "Tiết kiệm",
|
||||||
@@ -65,9 +52,10 @@
|
|||||||
"modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%",
|
"modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%",
|
||||||
"noModelData": "Chưa có dữ liệu mô hình",
|
"noModelData": "Chưa có dữ liệu mô hình",
|
||||||
"concurrency": "Đồng thời",
|
"concurrency": "Đồng thời",
|
||||||
"intervals_one": "{{count}} khoảng",
|
"intervals_one": "{{count}} loại agent",
|
||||||
"intervals_other": "{{count}} khoảng",
|
"intervals_other": "{{count}} loại agent",
|
||||||
"concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}",
|
"concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}",
|
||||||
|
"laneTip": "{{name}}\n{{count}} agent\nChạy từ {{start}}–{{end}}% thời lượng phiên (trung bình)",
|
||||||
"noConcurrencyData": "Chưa có dữ liệu đồng thời",
|
"noConcurrencyData": "Chưa có dữ liệu đồng thời",
|
||||||
"peak": "Đỉnh",
|
"peak": "Đỉnh",
|
||||||
"peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.",
|
"peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.",
|
||||||
@@ -99,11 +87,101 @@
|
|||||||
"synchronous": "Đồng bộ",
|
"synchronous": "Đồng bộ",
|
||||||
"autoVacuum": "Tự dọn dẹp",
|
"autoVacuum": "Tự dọn dẹp",
|
||||||
"foreignKeys": "Khóa ngoại",
|
"foreignKeys": "Khóa ngoại",
|
||||||
"busyTimeout": "Thời gian chờ khóa"
|
"busyTimeout": "Thời gian chờ khóa",
|
||||||
|
"groupApp": "AgentHub",
|
||||||
|
"groupAppHint": "Tình trạng của chính dashboard: điểm, cơ sở dữ liệu, số phiên đồng thời",
|
||||||
|
"groupMachine": "Máy chủ",
|
||||||
|
"groupMachineHint": "Máy đang chạy AgentHub: CPU, bộ nhớ, ổ đĩa, tiến trình",
|
||||||
|
"groupActivity": "Hoạt động agent",
|
||||||
|
"groupActivityHint": "Agent đã dùng gì: token theo model, công cụ, subagent",
|
||||||
|
"scoreFormula": "100 trừ đi phần mỗi mục bị trừ:",
|
||||||
|
"scoreResult": "= {{score}} / 100",
|
||||||
|
"check": {
|
||||||
|
"loop": "Phản hồi AgentHub",
|
||||||
|
"cpu": "Chờ CPU",
|
||||||
|
"memory": "Chờ bộ nhớ",
|
||||||
|
"io": "Chờ ổ đĩa",
|
||||||
|
"ram": "RAM đang dùng",
|
||||||
|
"disk": "Ổ chứa dữ liệu",
|
||||||
|
"heap": "V8 heap",
|
||||||
|
"hooks": "Hook gần nhất",
|
||||||
|
"agents": "Agent lỗi 24h"
|
||||||
|
},
|
||||||
|
"checkTip": {
|
||||||
|
"loop": "Event loop của AgentHub bị nghẽn bao lâu (p99 trong phút vừa qua). Mọi trang, hook và phím gõ trong terminal đều phải chờ chừng ấy.",
|
||||||
|
"cpu": "Áp lực CPU (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ CPU rảnh — vài % trở lên là máy bắt đầu chậm.",
|
||||||
|
"cpuLoad": "Tải mỗi lõi (1 phút) — máy này không có PSI nên ước lượng chờ CPU từ load.",
|
||||||
|
"memory": "Áp lực bộ nhớ (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ bị treo để thu hồi hoặc swap bộ nhớ.",
|
||||||
|
"io": "Áp lực I/O ổ đĩa (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ ổ đĩa — HDD chậm hoặc bận sẽ hiện ở đây.",
|
||||||
|
"ram": "Bộ nhớ đang dùng (tổng trừ phần còn khả dụng).",
|
||||||
|
"disk": "Mức đầy của ổ chứa cơ sở dữ liệu AgentHub ({{mount}}).",
|
||||||
|
"heap": "V8 heap của AgentHub so với giới hạn.",
|
||||||
|
"hooks": "Số phút từ hook gần nhất, chỉ tính khi có agent đang chạy: im lặng lúc đó nghĩa là dashboard không còn thấy agent.",
|
||||||
|
"agents": "Agent kết thúc với lỗi trong 24 giờ qua (lỗi / đã kết thúc; cần ít nhất 5)."
|
||||||
|
},
|
||||||
|
"checkNa": "Không đo được trên máy này — không trừ điểm.",
|
||||||
|
"checkThresholds": "Vàng từ {{warn}}{{unit}}, đỏ từ {{bad}}{{unit}}",
|
||||||
|
"checkPenalty": "Trừ {{n}} / tối đa {{max}} điểm",
|
||||||
|
"hooksMissing": "chưa cài",
|
||||||
|
"hooksIdle": "nghỉ",
|
||||||
|
"minutesAgo": "{{n}} phút trước",
|
||||||
|
"verdict": {
|
||||||
|
"ok": "Khỏe",
|
||||||
|
"warn": "Suy giảm",
|
||||||
|
"bad": "Có vấn đề"
|
||||||
|
},
|
||||||
|
"allOk": "Mọi mục đều ổn",
|
||||||
|
"badCount_one": "{{count}} đỏ",
|
||||||
|
"badCount_other": "{{count}} đỏ",
|
||||||
|
"warnCount_one": "{{count}} vàng",
|
||||||
|
"warnCount_other": "{{count}} vàng",
|
||||||
|
"lagGroup": "Độ trễ",
|
||||||
|
"systemGroup": "Tài nguyên & tín hiệu"
|
||||||
},
|
},
|
||||||
"errorBoundary": {
|
"errorBoundary": {
|
||||||
"title": "Đã xảy ra lỗi",
|
"title": "Đã xảy ra lỗi",
|
||||||
"body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.",
|
"body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.",
|
||||||
"reload": "Tải lại"
|
"reload": "Tải lại"
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
||||||
|
"uptimeHours": "{{h}} giờ {{m}} phút",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
||||||
|
"memory": "Bộ nhớ",
|
||||||
|
"swap": "Swap",
|
||||||
|
"free": "trống",
|
||||||
|
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
||||||
|
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
||||||
|
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
||||||
|
"homesBadge": "home",
|
||||||
|
"historyTitle": "Lịch sử",
|
||||||
|
"historyHelp": "Lưu trong bộ nhớ, mỗi {{s}} giây một điểm, từ khi máy chủ khởi động lúc {{at}}.",
|
||||||
|
"historyCpu": "CPU (% toàn máy)",
|
||||||
|
"historyMemory": "Bộ nhớ đã dùng (%)",
|
||||||
|
"range": "Khoảng thời gian",
|
||||||
|
"range1h": "1 giờ",
|
||||||
|
"range6h": "6 giờ",
|
||||||
|
"range24h": "24 giờ",
|
||||||
|
"coreN": "CPU {{n}}",
|
||||||
|
"threads": "{{n}} luồng",
|
||||||
|
"physical": "{{n}} lõi",
|
||||||
|
"perSecond": "{{v}}/giây",
|
||||||
|
"temperatures": "Nhiệt độ",
|
||||||
|
"topProcesses": "Tiến trình nặng nhất",
|
||||||
|
"cpuModel": "CPU",
|
||||||
|
"storageNetwork": "Ổ đĩa & mạng",
|
||||||
|
"processCount": "{{n}} tiến trình",
|
||||||
|
"title": "Máy chủ",
|
||||||
|
"os": "Hệ điều hành",
|
||||||
|
"uptime": "Thời gian chạy",
|
||||||
|
"load": "Tải (1/5/15 phút)",
|
||||||
|
"agenthubTip": "pid {{pid}} · Node {{node}}",
|
||||||
|
"sampling": "Đang lấy mẫu…",
|
||||||
|
"sampled": "mẫu {{s}} giây",
|
||||||
|
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
|
||||||
|
"agenthub": "AgentHub",
|
||||||
|
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
||||||
|
"usersCount": "{{n}} người dùng"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,7 @@
|
|||||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||||
"clone.nameLabel": "Tên thư mục",
|
"clone.nameLabel": "Tên thư mục",
|
||||||
"clone.cloning": "Đang clone…",
|
"clone.cloning": "Đang clone…",
|
||||||
|
"clone.background": "Chạy nền",
|
||||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||||
"clone.username": "Username",
|
"clone.username": "Username",
|
||||||
@@ -650,9 +651,29 @@
|
|||||||
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
|
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
|
||||||
"fx.myHome": "Thư mục home",
|
"fx.myHome": "Thư mục home",
|
||||||
"fx.userHomes": "Home của các user",
|
"fx.userHomes": "Home của các user",
|
||||||
|
"fx.users": "Người dùng",
|
||||||
|
"fx.toggleWorkspaces": "Hiện/ẩn workspace của {{name}}",
|
||||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
|
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
|
||||||
"fx.readOnly": "Chỉ đọc",
|
"fx.readOnly": "Chỉ đọc",
|
||||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
||||||
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
|
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}",
|
||||||
|
"changes.rescanRepos": "Quét lại các repository",
|
||||||
|
"tree.newGroup": "Nhóm mới",
|
||||||
|
"tree.groupDefaultName": "Nhóm mới",
|
||||||
|
"tree.ungrouped": "Chưa phân nhóm",
|
||||||
|
"tree.groupToggle": "Hiện hoặc ẩn nhóm {{name}}",
|
||||||
|
"tree.groupMenu": "Tuỳ chọn nhóm",
|
||||||
|
"tree.groupRename": "Đổi tên nhóm",
|
||||||
|
"tree.groupDelete": "Xoá nhóm",
|
||||||
|
"tree.groupMoveUp": "Đưa nhóm lên",
|
||||||
|
"tree.groupMoveDown": "Đưa nhóm xuống",
|
||||||
|
"tree.groupDeleteConfirm": "Xoá nhóm \"{{name}}\"? Các workspace trong nhóm vẫn giữ nguyên, chỉ không còn thuộc nhóm.",
|
||||||
|
"tree.groupNameLabel": "Tên nhóm",
|
||||||
|
"menu.moveToGroup": "Chuyển vào nhóm",
|
||||||
|
"menu.removeFromGroup": "Bỏ khỏi nhóm",
|
||||||
|
"menu.newGroupWith": "Nhóm mới…",
|
||||||
|
"tree.groupAddWorkspace": "Workspace mới trong nhóm này…",
|
||||||
|
"tree.groupFoldAll": "Thu gọn mọi nhóm",
|
||||||
|
"tree.groupUnfoldAll": "Mở rộng mọi nhóm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||||
"quotaActive": "đang dùng",
|
"quotaActive": "đang dùng",
|
||||||
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
|
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
|
||||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Cài đặt → AI tools → Hồ sơ đăng nhập để làm mới",
|
||||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||||
"quotaWindow": {
|
"quotaWindow": {
|
||||||
@@ -88,5 +88,6 @@
|
|||||||
"now": "bây giờ",
|
"now": "bây giờ",
|
||||||
"users": "Người dùng",
|
"users": "Người dùng",
|
||||||
"admin": "Quản trị",
|
"admin": "Quản trị",
|
||||||
"files": "Quản lý file"
|
"files": "Quản lý file",
|
||||||
|
"snapshot": "Snapshot"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"failed": "Không đính kèm được file",
|
"failed": "Không đính kèm được file",
|
||||||
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
||||||
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
||||||
|
"tooBigLocalPaste": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy copy file trong trình quản lý file rồi dán vào đây (Ctrl+V): file được dán sẽ dùng đường dẫn gốc",
|
||||||
"remove": "Bỏ đính kèm",
|
"remove": "Bỏ đính kèm",
|
||||||
"view": "Xem ảnh"
|
"view": "Xem ảnh"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
"tableAgents": "Agent",
|
"tableAgents": "Agent",
|
||||||
"tableCost": "Chi phí",
|
"tableCost": "Chi phí",
|
||||||
"tableDirectory": "Thư mục",
|
"tableDirectory": "Thư mục",
|
||||||
|
"tableUser": "Người dùng",
|
||||||
|
"tableTool": "AI Tool",
|
||||||
|
"tableActions": "Thao tác",
|
||||||
|
"noOwner": "Không có chủ",
|
||||||
|
"moreActions": "Thao tác khác",
|
||||||
|
"viewDetails": "Xem chi tiết",
|
||||||
"defaultName": "Phiên ",
|
"defaultName": "Phiên ",
|
||||||
"detail": {
|
"detail": {
|
||||||
"waitingBanner": {
|
"waitingBanner": {
|
||||||
|
|||||||
@@ -77,26 +77,10 @@
|
|||||||
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||||
},
|
},
|
||||||
"greet": {
|
"greet": {
|
||||||
"morning": [
|
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
|
||||||
"chào buổi sáng! ☀️",
|
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
|
||||||
"sáng rồi! cà phê chưa? ☕",
|
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
|
||||||
"ngày mới tốt lành nha 🌤️"
|
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
|
||||||
],
|
|
||||||
"afternoon": [
|
|
||||||
"chào buổi chiều! 🐾",
|
|
||||||
"bạn quay lại rồi 😺",
|
|
||||||
"chiều nay làm gì đây? ✨"
|
|
||||||
],
|
|
||||||
"evening": [
|
|
||||||
"chào buổi tối! 🌆",
|
|
||||||
"ca tối hả? có mình đây 😼",
|
|
||||||
"tối rồi, cố lên nhé 🌙"
|
|
||||||
],
|
|
||||||
"night": [
|
|
||||||
"khuya rồi mà vẫn làm à? 🌙",
|
|
||||||
"để mình canh giúp cho 🦉",
|
|
||||||
"nhớ nghỉ ngơi nha 💤"
|
|
||||||
],
|
|
||||||
"summary_one": "{{count}} phiên đang chạy",
|
"summary_one": "{{count}} phiên đang chạy",
|
||||||
"summary_other": "{{count}} phiên đang chạy",
|
"summary_other": "{{count}} phiên đang chạy",
|
||||||
"summaryWaiting": "{{count}} phiên chờ bạn",
|
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||||
@@ -114,11 +98,7 @@
|
|||||||
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
||||||
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
||||||
"hello": "chào bạn! 😺 {{summary}}.",
|
"hello": "chào bạn! 😺 {{summary}}.",
|
||||||
"thanks": [
|
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
|
||||||
"có gì đâu! 💜",
|
|
||||||
"rất vui được giúp 😸",
|
|
||||||
"chuyện nhỏ 🐾"
|
|
||||||
],
|
|
||||||
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||||
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||||
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||||
@@ -175,41 +155,13 @@
|
|||||||
"không gọi được máy chủ 📡",
|
"không gọi được máy chủ 📡",
|
||||||
"mất sóng - đi ngủ chút 💤"
|
"mất sóng - đi ngủ chút 💤"
|
||||||
],
|
],
|
||||||
"worried": [
|
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
|
||||||
"có vẻ không ổn 😟",
|
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
|
||||||
"mình đang để mắt đây 👀",
|
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
|
||||||
"hừm, có gì đó lạ lạ 🫣"
|
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
|
||||||
],
|
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
||||||
"stuck": [
|
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
|
||||||
"một phiên im lặng lâu rồi… 🤔",
|
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
||||||
"có gì bị kẹt không? ⏳",
|
|
||||||
"vẫn đang nhai… 😾"
|
|
||||||
],
|
|
||||||
"happy": [
|
|
||||||
"tuyệt vời! 😻",
|
|
||||||
"kết thúc gọn ghê ✨",
|
|
||||||
"hoàn hảo 💜"
|
|
||||||
],
|
|
||||||
"thinking": [
|
|
||||||
"để mình xem… 🤔",
|
|
||||||
"đang đánh hơi… 🐾",
|
|
||||||
"chờ chút, đang kiểm tra 🔍"
|
|
||||||
],
|
|
||||||
"watching": [
|
|
||||||
"đang tuần tra 👀",
|
|
||||||
"đang canh các phiên của bạn 😼",
|
|
||||||
"mắt mở to 🐾"
|
|
||||||
],
|
|
||||||
"sleeping": [
|
|
||||||
"zzz… 💤",
|
|
||||||
"có gì thì gọi mình dậy nhé 😴",
|
|
||||||
"cuộn tròn, mọi thứ yên ổn 🐈"
|
|
||||||
],
|
|
||||||
"idle": [
|
|
||||||
"yên ắng quá 😺",
|
|
||||||
"sẵn sàng khi bạn cần 🐾",
|
|
||||||
"chill thôi ✨"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"label": "Trò chuyện với {{name}}",
|
"label": "Trò chuyện với {{name}}",
|
||||||
@@ -795,6 +747,29 @@
|
|||||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||||
"add": "Thêm profile",
|
"add": "Thêm profile",
|
||||||
"config9router": "Config 9router",
|
"config9router": "Config 9router",
|
||||||
|
"update9router": "Cập nhật 9router",
|
||||||
|
"update9routerAvailable": "Đã có bản 9router mới",
|
||||||
|
"update9routerCurrent": "Đang cài",
|
||||||
|
"update9routerLatest": "Mới nhất",
|
||||||
|
"update9routerState": "Trạng thái",
|
||||||
|
"update9routerRunning": "Đang chạy",
|
||||||
|
"update9routerRunningUnit": "Đang chạy (9router.service)",
|
||||||
|
"update9routerStopped": "Không chạy",
|
||||||
|
"update9routerExplain": "Mọi session đang dùng 9router sẽ bị ngắt kết nối trong lúc cập nhật: 9router được tắt, cập nhật bằng npm rồi mở lại ở chế độ ẩn (khay hệ thống, không mở trình duyệt).",
|
||||||
|
"update9routerExplainStopped": "9router đang không chạy: chỉ cập nhật bằng npm và giữ nguyên trạng thái tắt.",
|
||||||
|
"update9routerStep": {
|
||||||
|
"shutdown": "Ngắt kết nối và tắt 9router",
|
||||||
|
"update": "Chạy cập nhật (npm install -g 9router@latest)",
|
||||||
|
"relaunch": "Mở lại 9router ở chế độ ẩn"
|
||||||
|
},
|
||||||
|
"update9routerDone": "9router {{version}} đang chạy.",
|
||||||
|
"update9routerDoneStopped": "Đã cài 9router {{version}}, giữ nguyên trạng thái tắt như trước.",
|
||||||
|
"update9routerFailed": "Cập nhật chưa hoàn tất — xem các bước và log.",
|
||||||
|
"update9routerLog": "Output của npm",
|
||||||
|
"update9routerUpToDate": "9router đã là bản mới nhất.",
|
||||||
|
"update9routerCopy": "Sao chép lệnh",
|
||||||
|
"update9routerClose": "Đóng",
|
||||||
|
"update9routerStart": "Cập nhật ngay",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"remove": "Xoá",
|
"remove": "Xoá",
|
||||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||||
@@ -920,7 +895,6 @@
|
|||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
"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: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
||||||
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
|
|
||||||
"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.",
|
||||||
@@ -930,7 +904,6 @@
|
|||||||
"awsRegion": "Region",
|
"awsRegion": "Region",
|
||||||
"awsSave": "Lưu",
|
"awsSave": "Lưu",
|
||||||
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
||||||
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
|
|
||||||
"otherTitle": "Host SSH khác (nâng cao)",
|
"otherTitle": "Host SSH khác (nâng cao)",
|
||||||
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
||||||
"otherHost": "Host",
|
"otherHost": "Host",
|
||||||
@@ -944,16 +917,14 @@
|
|||||||
"alias": "Tên tài khoản",
|
"alias": "Tên tài khoản",
|
||||||
"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? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
"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 đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||||
"moreKeysTitle": "Thêm SSH key",
|
|
||||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
|
||||||
"newKey": "Key mới",
|
|
||||||
"keyName": "Tên key",
|
|
||||||
"keyNamePlaceholder": "vd aws-prod",
|
|
||||||
"keyType": "Loại key",
|
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "GitHub · GitLab",
|
||||||
"tabMore": "Key khác",
|
"tabOther": "Host khác",
|
||||||
"tabOther": "Host khác"
|
"addKey": "Thêm key khác",
|
||||||
|
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
||||||
|
"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.",
|
||||||
|
"removeKeyNamed": "Xoá key {{name}}"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
@@ -1073,6 +1044,10 @@
|
|||||||
"title": "Claude Code",
|
"title": "Claude Code",
|
||||||
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
|
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
|
||||||
},
|
},
|
||||||
|
"claudeCodeOwn": {
|
||||||
|
"title": "Cấu hình Claude Code của bạn",
|
||||||
|
"description": "Skill, agent, plugin, MCP server và thiết lập mà các phiên của chính bạn dùng."
|
||||||
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"title": "Codex",
|
"title": "Codex",
|
||||||
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
|
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
|
||||||
@@ -1113,6 +1088,12 @@
|
|||||||
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
|
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
|
||||||
"results": "Kết quả tìm kiếm"
|
"results": "Kết quả tìm kiếm"
|
||||||
},
|
},
|
||||||
|
"changesPanel": {
|
||||||
|
"title": "Panel Changes",
|
||||||
|
"description": "Dành cho thư mục workspace chứa nhiều repo git (mỗi repo có .git riêng). Lưu theo tài khoản của bạn.",
|
||||||
|
"innerRepos": "Hiện các repo bên trong thư mục workspace",
|
||||||
|
"innerReposDesc": "Mỗi repo tìm thấy trong thư mục (sâu tối đa hai cấp) có một mục riêng trong panel Changes. Tắt: chỉ hiện git của chính thư mục."
|
||||||
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Gợi ý worktree",
|
"title": "Gợi ý worktree",
|
||||||
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
|
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
|
||||||
@@ -1172,6 +1153,10 @@
|
|||||||
"18": "Rất lớn"
|
"18": "Rất lớn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nativePaste": {
|
||||||
|
"label": "Dán ảnh kiểu native",
|
||||||
|
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Trên Linux cần wl-paste hoặc xclip; thiếu thì ảnh được upload thay thế. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
|
||||||
|
},
|
||||||
"terminalFont": {
|
"terminalFont": {
|
||||||
"title": "Font terminal",
|
"title": "Font terminal",
|
||||||
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
||||||
@@ -1357,7 +1342,8 @@
|
|||||||
"tooBig": "Ảnh tối đa 1 MB",
|
"tooBig": "Ảnh tối đa 1 MB",
|
||||||
"fromUrl": "Dùng ảnh từ đường dẫn",
|
"fromUrl": "Dùng ảnh từ đường dẫn",
|
||||||
"urlPlaceholder": "https://… đường dẫn ảnh",
|
"urlPlaceholder": "https://… đường dẫn ảnh",
|
||||||
"urlApply": "Dùng"
|
"urlApply": "Dùng",
|
||||||
|
"linkUnreachable": "Trình duyệt không tải được ảnh từ link này — có thể link cần đăng nhập (ảnh Mattermost hay app chat khác là vậy). Hãy tải ảnh về rồi upload lên."
|
||||||
},
|
},
|
||||||
"loadFailed": "Không tải được tài khoản: {{message}}",
|
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||||
"memberSince": "Tham gia từ",
|
"memberSince": "Tham gia từ",
|
||||||
@@ -1385,5 +1371,46 @@
|
|||||||
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
|
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
|
||||||
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
|
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
|
||||||
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
|
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
|
||||||
|
},
|
||||||
|
"toolProfiles": {
|
||||||
|
"title": "Hồ sơ đăng nhập",
|
||||||
|
"description": "Mọi đăng nhập đã lưu của các AI tool ở một nơi. Khi đổi chỉ phần đăng nhập được hoán đổi; config, skills, phiên làm việc và lịch sử vẫn giữ nguyên.",
|
||||||
|
"poolCount_one": "Account pool đang có {{count}} tài khoản cho tool này.",
|
||||||
|
"poolCount_other": "Account pool đang có {{count}} tài khoản cho tool này.",
|
||||||
|
"openPool": "Mở account pool",
|
||||||
|
"moveToPool": "Đưa vào pool",
|
||||||
|
"moveToPoolHint": "Quản trị → Người dùng → Account pool, chọn sẵn hồ sơ này. Pool sẽ là nơi duy nhất giữ đăng nhập này và hồ sơ được xoá.",
|
||||||
|
"saveButton": "Lưu đăng nhập này thành hồ sơ",
|
||||||
|
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||||
|
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||||
|
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||||
|
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||||
|
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||||
|
"subtitle": {
|
||||||
|
"codex": "Mỗi hồ sơ là một đăng nhập Codex (tài khoản ChatGPT hoặc API key): toàn bộ auth.json. config.toml, phiên, skills và lịch sử vẫn giữ nguyên.",
|
||||||
|
"omp": "Mỗi hồ sơ là toàn bộ các đăng nhập Oh My Pi đang lưu (mọi provider), đọc bằng `omp auth export` và ghi lại bằng `omp auth import`. Settings, model và phiên vẫn giữ nguyên. Các phiên omp đang chạy tự nhận đăng nhập mới."
|
||||||
|
},
|
||||||
|
"unsupported": {
|
||||||
|
"antigravity": "Antigravity lưu đăng nhập trong keyring của hệ điều hành, AgentHub không đọc hay ghi vào đó nên không đổi được từ đây. Hãy đăng nhập/đăng xuất ngay trong agy."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"chatgpt": "Đăng nhập ChatGPT",
|
||||||
|
"apikey": "API key",
|
||||||
|
"credentials": "Đăng nhập"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"poolAccount": {
|
||||||
|
"title": "Tài khoản pool",
|
||||||
|
"description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.",
|
||||||
|
"automatic": "Tự động",
|
||||||
|
"select": "Tài khoản pool của {{tool}}",
|
||||||
|
"count_one": "{{count}} tài khoản bạn được dùng",
|
||||||
|
"count_other": "{{count}} tài khoản bạn được dùng",
|
||||||
|
"pinned": "Admin đã ghim bạn vào {{account}}",
|
||||||
|
"expired": "hết hạn",
|
||||||
|
"full": "hết quota",
|
||||||
|
"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",
|
||||||
|
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,10 @@
|
|||||||
"autoFix": "Tự sửa",
|
"autoFix": "Tự sửa",
|
||||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||||
"autoFixError": "Không mở được phiên Tự sửa"
|
"autoFixError": "Không mở được phiên Tự sửa",
|
||||||
|
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||||
|
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
||||||
|
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||||
|
"pluginStep.plugin_update": "Bước {{index}}/{{total}}: tải phiên bản mới",
|
||||||
|
"pluginStep.bootstrap": "Bước {{index}}/{{total}}: cài đặt và khởi động lại bảng điều khiển"
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user