Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+2
-2
@@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -2557,7 +2557,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
|
||||
| **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled |
|
||||
| **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module |
|
||||
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
||||
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||
| **React 19** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
|
||||
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
||||
|
||||
+67
-69
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
|
||||
// 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 —
|
||||
// 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.
|
||||
try {
|
||||
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) {
|
||||
const flag = (name) => {
|
||||
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
|
||||
if (base) body.base = base;
|
||||
if (slug) body.slug = slug;
|
||||
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;
|
||||
let lane = provisioning;
|
||||
while (lane.status === "provisioning" && Date.now() < deadline) {
|
||||
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") {
|
||||
console.error(
|
||||
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
|
||||
const body = { cwd, title };
|
||||
if (pipeline) body.pipeline = pipeline;
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `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
|
||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||
* 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
|
||||
* 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
|
||||
* 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`.
|
||||
*/
|
||||
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) {
|
||||
const id = args.find((arg) => !arg.startsWith("--"));
|
||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
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);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
const body = { confirm: true, expect };
|
||||
if (args.includes("--force")) body.force = 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) {
|
||||
const error = result.data?.error || {};
|
||||
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.
|
||||
*/
|
||||
/**
|
||||
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
|
||||
}
|
||||
|
||||
async function cmdLanes() {
|
||||
const { lanes, counts } = await get("/api/lanes");
|
||||
const { lanes, counts } = await get("/api/workspaces");
|
||||
if (!lanes.length) {
|
||||
console.log(
|
||||
"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.
|
||||
* 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.
|
||||
* @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;
|
||||
};
|
||||
|
||||
const explicit = flagValue("lane") || flagValue("workspace");
|
||||
const explicit = flagValue("workspace");
|
||||
if (explicit) return { laneId: explicit, rest: args };
|
||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||
|
||||
const tool = flagValue("tool") || toolFromEnv();
|
||||
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
|
||||
// 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
|
||||
* lane id.
|
||||
*
|
||||
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const { laneId, rest: laneArgs } = resolved;
|
||||
|
||||
if (sub === "runtime") {
|
||||
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
||||
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const body = {};
|
||||
if (laneArgs.includes("--no-build")) body.build = false;
|
||||
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) {
|
||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
for (;;) {
|
||||
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) {
|
||||
printRuntime(runtime);
|
||||
return;
|
||||
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error(
|
||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
const i = laneArgs.indexOf("--tail");
|
||||
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) {
|
||||
console.log("no logs for this workspace yet.");
|
||||
return;
|
||||
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
return;
|
||||
}
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
{ args: laneArgs.slice(1) },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
: "merge";
|
||||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/sync-base`,
|
||||
`/api/workspaces/${laneId}/sync-base`,
|
||||
{ mode, branch },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
|
||||
|
||||
const resolved = await resolveLaneArg(argsAfterName);
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
|
||||
async function cmdFeatureList(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
||||
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
|
||||
if (!features.length) {
|
||||
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
||||
return;
|
||||
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
|
||||
};
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
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,
|
||||
title: flag("title"),
|
||||
});
|
||||
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
|
||||
}
|
||||
|
||||
/** `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
|
||||
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||
async function cmdLanesProofLink(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
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(
|
||||
linked
|
||||
? `${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
|
||||
* profiles. Never run automatically; a session calls it explicitly. */
|
||||
async function cmdLanesMcpSync(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
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) {
|
||||
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||
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
|
||||
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||
async function cmdLanesIntegration(args) {
|
||||
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||
if (!resolved) return;
|
||||
const { enabled } = await get(
|
||||
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
);
|
||||
console.log(enabled ? "on" : "off");
|
||||
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;
|
||||
* 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. */
|
||||
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
|
||||
const resolved = await resolveLaneArg(args.slice(2));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
undefined,
|
||||
{ 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.
|
||||
*
|
||||
* 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:
|
||||
*
|
||||
* - 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
|
||||
* 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 resolved = await resolveLaneArg(args);
|
||||
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 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 nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
||||
...n,
|
||||
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub lanes add --pipeline` could only set
|
||||
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* 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"
|
||||
* 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 (!target) {
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
|
||||
console.log(
|
||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
|
||||
async function cmdLanesShell(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
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 child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||
stdio: "inherit",
|
||||
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
|
||||
if (!resolved) return;
|
||||
const laneId = resolved.laneId;
|
||||
|
||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
||||
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
|
||||
stage,
|
||||
status: flag("status"),
|
||||
evidence: flag("evidence"),
|
||||
@@ -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.
|
||||
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
|
||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
|
||||
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
|
||||
${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).
|
||||
@@ -3512,8 +3512,8 @@ function replHelp() {
|
||||
/** Detailed help for one command (or command prefix): every catalog entry
|
||||
* whose invocation starts with the token. Returns false if none matched. */
|
||||
function commandHelp(token) {
|
||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
||||
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
|
||||
// `help workspaces` finds the `workspace …` rows and vice versa.
|
||||
const canon = (words) => {
|
||||
const [head, ...tail] = words.split(" ");
|
||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
|
||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||
// decides whether to fall back to an offline reader or report the refusal.
|
||||
|
||||
/** Command names that are another command under the user-facing word.
|
||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
||||
* `--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" };
|
||||
/** Command names that are another command under the user-facing word. */
|
||||
const COMMAND_ALIASES = { workspaces: "workspace" };
|
||||
const canonicalArgv = (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
|
||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
||||
function warnOldNames(argv) {
|
||||
const old = [];
|
||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
||||
if (old.length) {
|
||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
||||
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
|
||||
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
|
||||
* — never "Unknown command" or help, so a script written before says why. */
|
||||
function refuseOldNames(argv) {
|
||||
if (argv[0] === "lanes") {
|
||||
console.error(c.red("✖ renamed: use agenthub workspace …"));
|
||||
process.exit(1);
|
||||
}
|
||||
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]);
|
||||
}
|
||||
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);
|
||||
return [...set];
|
||||
})();
|
||||
@@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
* points, not here, to avoid recursive REPLs. */
|
||||
async function runCommand(argv) {
|
||||
warnOldNames(argv);
|
||||
refuseOldNames(argv);
|
||||
const [cmd, ...rest] = canonicalArgv(argv);
|
||||
const { flags, positional } = parseArgs(rest);
|
||||
switch (cmd) {
|
||||
@@ -4571,7 +4569,7 @@ async function runCommand(argv) {
|
||||
return cmdReinstallHooks();
|
||||
case "update-check":
|
||||
return cmdUpdateCheck();
|
||||
case "lanes":
|
||||
case "workspace":
|
||||
if (rest[0] === "add") {
|
||||
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:
|
||||
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **React 19** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
|
||||
- **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`)
|
||||
|
||||
@@ -1215,7 +1215,7 @@ graph TB
|
||||
|
||||
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
|
||||
- 🧪 **Tested** - Vitest + React Testing Library
|
||||
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
||||
|
||||
Generated
+427
-139
@@ -15,18 +15,18 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
@@ -34,17 +34,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
@@ -450,10 +450,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
||||
"license": "MIT",
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
|
||||
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -478,10 +477,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
||||
"license": "MIT",
|
||||
"version": "6.43.15",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1661,7 +1659,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
@@ -2108,30 +2105,21 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"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": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"license": "MIT",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
@@ -2140,6 +2128,326 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||
@@ -2282,59 +2590,38 @@
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
|
||||
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
|
||||
"license": "MIT",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
|
||||
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"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": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
@@ -3706,9 +3993,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"version": "26.5.11",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -3723,7 +4010,6 @@
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
@@ -3734,12 +4020,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
|
||||
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
"@babel/runtime": "^7.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
@@ -3924,6 +4209,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
@@ -4022,18 +4308,6 @@
|
||||
"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": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -4052,12 +4326,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.474.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
|
||||
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
|
||||
"license": "ISC",
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
@@ -5378,35 +5657,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
|
||||
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.16",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
||||
"license": "MIT",
|
||||
"version": "17.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
@@ -5755,13 +6027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
@@ -5774,9 +6042,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
@@ -6183,17 +6451,37 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"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": {
|
||||
|
||||
+13
-13
@@ -18,18 +18,18 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
@@ -37,17 +37,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"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>
|
||||
<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="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 transform="translate(792,300)">
|
||||
<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)}`;
|
||||
} else if (path.startsWith("/sessions")) {
|
||||
title = t("sessions");
|
||||
} else if (path.startsWith("/snapshot/")) {
|
||||
title = t("snapshot");
|
||||
} else if (path.startsWith("/workspace")) {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/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 !== "/") {
|
||||
title = t("notFound");
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
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.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
|
||||
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(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 bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the session
|
||||
* header looks as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* 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
|
||||
* 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,
|
||||
@@ -50,9 +51,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
@@ -281,7 +279,7 @@ function QuotaChips() {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
const closeTimer = useRef<number>();
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const show = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
@@ -406,7 +404,7 @@ export function StatusBar({
|
||||
const gen = costGen.current;
|
||||
const live = () => gen === costGen.current;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.totalCost({ date: localDate(), mine: true })
|
||||
.then((r) => live() && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only — also while an update applies: it keeps running in the
|
||||
* background (the page still reloads itself once the new server is up), and a
|
||||
* failure re-opens the modal with its error; "Skip this version" is the one
|
||||
* 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
|
||||
* and starts a Claude Code session there with the error as its prompt — the
|
||||
@@ -184,6 +189,8 @@ export function UpdateNotifier() {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = 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 syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
@@ -238,13 +245,14 @@ export function UpdateNotifier() {
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(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(
|
||||
isAdmin &&
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
closedKey !== dismissKey &&
|
||||
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
|
||||
);
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
@@ -252,9 +260,8 @@ export function UpdateNotifier() {
|
||||
// poll that reloads the page keep running, and a failure re-opens it.
|
||||
const closeForNow = useCallback(() => {
|
||||
if (!dismissKey) return;
|
||||
if (applying || restarting) toast.info(t("backgroundNote"));
|
||||
setClosedKey(dismissKey);
|
||||
}, [applying, restarting, dismissKey, t]);
|
||||
}, [dismissKey]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
@@ -312,6 +319,7 @@ export function UpdateNotifier() {
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
setProgress(s.plugin_update_progress ?? null);
|
||||
const v = s.last_plugin_update;
|
||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||
@@ -339,9 +347,23 @@ export function UpdateNotifier() {
|
||||
[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 () => {
|
||||
if (applying || restarting) return;
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setApplying(true);
|
||||
try {
|
||||
const result = await api.updates.apply();
|
||||
@@ -392,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;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -520,7 +590,14 @@ export function UpdateNotifier() {
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
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>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -30,7 +40,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
@@ -46,7 +56,31 @@ import {
|
||||
writeLaneOrder,
|
||||
} from "../lib/laneOrderStorage";
|
||||
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 { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
|
||||
);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
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 [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
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
const openAdd = (sourceRepo?: string) => {
|
||||
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
|
||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||
setAddToGroup(groupId);
|
||||
setAddOpen(true);
|
||||
api.run
|
||||
.cwds()
|
||||
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
|
||||
const toolFilter =
|
||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||
const sorted = useMemo(() => {
|
||||
const list = sortByStoredOrder(lanes, order).filter(
|
||||
(l) => !toolFilter || l.tool === toolFilter
|
||||
const list = arrangeByGroup(
|
||||
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||
groups
|
||||
);
|
||||
if (!grouped) return list;
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
|
||||
return [...list].sort(
|
||||
(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 nameHit = (l: Lane) =>
|
||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
|
||||
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
|
||||
// workspace's session list, the space above or below a row — lands too.
|
||||
// `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);
|
||||
// Number("") is 0: an empty payload must not move the first row.
|
||||
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);
|
||||
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) => {
|
||||
setRenamingId(null);
|
||||
if (title === null || title === (lane.title || "")) return;
|
||||
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</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
|
||||
type="button"
|
||||
data-testid="workspace-tree-add"
|
||||
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
|
||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
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 (
|
||||
<div key={lane.id} className="relative">
|
||||
{groupStart && groupShown && (
|
||||
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
|
||||
{sectionStart &&
|
||||
sectionShown &&
|
||||
!folded &&
|
||||
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && !folded && (
|
||||
{visible(lane) && !hiddenRow && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onReorder={nudge}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && !folded && (
|
||||
{expanded(lane.id) && !hiddenRow && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{firstUngrouped === -1 &&
|
||||
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
|
||||
{dropAt === sorted.length && (
|
||||
<div className="relative">
|
||||
<DropLine />
|
||||
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
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}>
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
setAddToGroup(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
if (addToGroup) assignGroup(lane.id, addToGroup);
|
||||
void refreshLanes();
|
||||
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". */
|
||||
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. */
|
||||
function DropLine() {
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
|
||||
function renderCard(element: JSX.Element) {
|
||||
function renderCard(element: React.JSX.Element) {
|
||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
|
||||
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
||||
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
||||
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 { 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>);
|
||||
|
||||
describe("StatusBar", () => {
|
||||
@@ -162,7 +162,7 @@ describe("StatusBar", () => {
|
||||
const conn = screen.getByRole("button", { name: /Live/ });
|
||||
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
||||
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(
|
||||
"title",
|
||||
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
||||
|
||||
@@ -312,6 +312,91 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — Auto fix", () => {
|
||||
it("offers no Not now button, and Auto fix only once an update failed", async () => {
|
||||
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||
* selection
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -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 { WorkspaceTree } from "../WorkspaceTree";
|
||||
import { workspaceDot } from "../lanes/WorkspaceRow";
|
||||
@@ -576,6 +592,185 @@ describe("WorkspaceTree", () => {
|
||||
menu = screen.getByTestId("workspace-row-menu-3");
|
||||
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", () => {
|
||||
|
||||
@@ -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 { throttle } from "../../lib/throttle";
|
||||
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 /
|
||||
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
||||
@@ -175,6 +180,8 @@ function ConversationViewPane({
|
||||
initialTranscriptId ?? null
|
||||
);
|
||||
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 [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||
@@ -283,6 +290,7 @@ function ConversationViewPane({
|
||||
if (cancelled) return;
|
||||
setNotStarted(false);
|
||||
setMessages(result.messages);
|
||||
setLeadEdits(result.turn_edits);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -343,6 +351,7 @@ function ConversationViewPane({
|
||||
// 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.
|
||||
setMessages(result.messages);
|
||||
setLeadEdits(result.turn_edits);
|
||||
firstLineRef.current = result.first_line;
|
||||
setHasMore(result.has_more);
|
||||
} else {
|
||||
@@ -487,6 +496,7 @@ function ConversationViewPane({
|
||||
firstLineRef.current = result.first_line;
|
||||
|
||||
setMessages((prev) => [...result.messages, ...prev]);
|
||||
setLeadEdits(result.turn_edits);
|
||||
setHasMore(result.has_more);
|
||||
|
||||
// Preserve scroll position (don't jump to top)
|
||||
@@ -686,6 +696,7 @@ function ConversationViewPane({
|
||||
stopped={stopped}
|
||||
agentLinks={agentLinks}
|
||||
compact={compact}
|
||||
leadEdits={leadEdits}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
|
||||
@@ -172,6 +172,9 @@ interface MessageListProps {
|
||||
/** A subagent's conversation inside its step: tight spacing, and the task
|
||||
* the orchestrator gave it reads as the prompt it is. */
|
||||
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 */
|
||||
@@ -491,6 +494,10 @@ type Item =
|
||||
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
||||
|
||||
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
|
||||
* writes first, the command, what the command printed — or null for
|
||||
@@ -515,11 +522,18 @@ export function localCommand(
|
||||
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[] = [];
|
||||
// The file-changing calls of the turn so far, closed into one card when the
|
||||
// human speaks again (or the list ends).
|
||||
let edits: TranscriptContent[] = [];
|
||||
// human speaks again (or the list ends) — starting with what the page's
|
||||
// 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,
|
||||
// for the "Done in …" line under a turn that ended.
|
||||
let start: string | null = null;
|
||||
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
if (msg.usage) output.add(msg.usage);
|
||||
msg.content.forEach((block, b) => {
|
||||
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.
|
||||
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||
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 }>();
|
||||
// Written whole in this turn (a Write): a component made here is an artifact.
|
||||
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) {
|
||||
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 p = input.file_path ?? input.notebook_path;
|
||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||
if (b.name === "Write") created.add(p);
|
||||
byAgent.delete(p);
|
||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||
f.added += r.diff?.added ?? 0;
|
||||
f.removed += r.diff?.removed ?? 0;
|
||||
f.counted ||= !!r.diff;
|
||||
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 && (
|
||||
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
||||
{artifacts.map((p) => (
|
||||
@@ -773,6 +810,7 @@ function MessageListView({
|
||||
sessionId,
|
||||
agentLinks,
|
||||
compact = false,
|
||||
leadEdits,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
@@ -798,9 +836,10 @@ function MessageListView({
|
||||
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
|
||||
)
|
||||
: messages,
|
||||
keys
|
||||
keys,
|
||||
leadEdits
|
||||
),
|
||||
[messages, keys, compact]
|
||||
[messages, keys, compact, leadEdits]
|
||||
);
|
||||
const agents = useMemo(
|
||||
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
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)
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
/**
|
||||
* @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; 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>
|
||||
<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">
|
||||
<HealthBar
|
||||
label={t("host.cpu")}
|
||||
value={s.usage ? s.usage.cpu : null}
|
||||
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");
|
||||
|
||||
// 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. */}
|
||||
<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">
|
||||
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
||||
<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. */}
|
||||
<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,182 @@
|
||||
/**
|
||||
* @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).
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -18,6 +22,8 @@ import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -26,12 +32,15 @@ export function CloneHereDialog({
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
onReopen,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
/** Show the dialog again from the background chip. */
|
||||
onReopen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,10 +51,23 @@ export function CloneHereDialog({
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
if (!open || busyRef.current) return;
|
||||
if (keepRef.current) {
|
||||
keepRef.current = false;
|
||||
return;
|
||||
}
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
@@ -54,14 +76,39 @@ export function CloneHereDialog({
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
if (!open) return null;
|
||||
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 u = url.trim();
|
||||
if (!u || busy) return;
|
||||
const progressToken = newCloneToken();
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setToken(progressToken);
|
||||
setTarget(into);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
@@ -69,6 +116,7 @@ export function CloneHereDialog({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
progressToken,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
@@ -79,12 +127,16 @@ export function CloneHereDialog({
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
onCloned(result.lane, result.path);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
let message: string;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
@@ -93,18 +145,25 @@ export function CloneHereDialog({
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
message = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
setError(message);
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
if (!openRef.current) {
|
||||
keepRef.current = true;
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
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
|
||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||
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"
|
||||
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" />
|
||||
{t("fx.cloneRepo")}
|
||||
</h2>
|
||||
@@ -162,7 +224,10 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
</div>
|
||||
{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
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
@@ -193,6 +258,7 @@ export function CloneHereDialog({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{busy && token && <CloneProgress token={token} />}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
@@ -204,8 +270,8 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||
{t("fx.cancel")}
|
||||
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||
{busy ? t("clone.background") : t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
type FsTrashItem,
|
||||
type FsHome,
|
||||
type FsPlace,
|
||||
type GitSource,
|
||||
isHomePlace,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
@@ -283,7 +284,7 @@ export function FileBrowser({
|
||||
/** A home to open on (`me`, or another user's for an admin). */
|
||||
initialHome?: string | null;
|
||||
session?: string | null;
|
||||
source?: number | null;
|
||||
source?: GitSource | null;
|
||||
initialPath?: string;
|
||||
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
||||
onChanged?: () => void;
|
||||
@@ -974,6 +975,7 @@ export function FileBrowser({
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onReopen={() => setCloning(true)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
@@ -1881,7 +1883,7 @@ export function FileExplorer({
|
||||
onClose: () => void;
|
||||
laneId: number;
|
||||
session?: string | null;
|
||||
source?: number | null;
|
||||
source?: GitSource | null;
|
||||
initialPath?: string;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
|
||||
@@ -103,7 +103,7 @@ import {
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
@@ -827,7 +827,7 @@ export function LaneChangesPanel({
|
||||
session?: string;
|
||||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||
* wins over `session`, and is keyed on like it. */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import {
|
||||
applyChoices,
|
||||
parseConflicts,
|
||||
@@ -47,7 +47,7 @@ export function LaneConflictView({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} 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 { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
@@ -98,7 +98,7 @@ export function LaneGitBar({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
git: LaneGitFacts;
|
||||
/** 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. */
|
||||
|
||||
@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
@@ -37,8 +37,8 @@ export function useLaneGitFacts(
|
||||
laneId: number,
|
||||
/** Read that session's own worktree instead (`?session=`). */
|
||||
session?: string,
|
||||
/** Read that extra folder's repo instead (`?source=`). */
|
||||
source?: number
|
||||
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
|
||||
source?: GitSource
|
||||
): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
@@ -329,7 +329,7 @@ export function LaneGitRailCounts({
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
@@ -349,7 +349,7 @@ export function LaneGitHeader({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||
* existing call: Rename (the row's inline field), New workspace here (the
|
||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* 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,
|
||||
* Escape / outside-click close) is `ContextMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -11,7 +13,9 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { Check } from "lucide-react";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
|
||||
y,
|
||||
onClose,
|
||||
onAction,
|
||||
groups = [],
|
||||
groupId = null,
|
||||
onGroup,
|
||||
}: {
|
||||
lane: Lane;
|
||||
x: number;
|
||||
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
|
||||
onClose: () => void;
|
||||
/** Items the tree itself carries out (rename field, modals). */
|
||||
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");
|
||||
return (
|
||||
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</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 />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
|
||||
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
|
||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("goose", "Goose", { enabled: false }),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneProgress: vi.fn(),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -553,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
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 () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
|
||||
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
|
||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||
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);
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
await user.click(folder);
|
||||
|
||||
@@ -765,7 +765,7 @@ export function Divider({
|
||||
onRatio,
|
||||
label,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* 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
|
||||
* 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;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
@@ -78,6 +79,8 @@ import {
|
||||
localAttachment,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
fileManagerPaths,
|
||||
isClipboardBitmap,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -463,21 +466,26 @@ export function PromptEditor({
|
||||
};
|
||||
/** 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
|
||||
* 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 = (
|
||||
dt: DataTransfer | null | undefined,
|
||||
list: FileList | null | undefined
|
||||
list: FileList | File[] | null | undefined,
|
||||
from: "paste" | "drop"
|
||||
) => {
|
||||
const files = Array.from(list ?? []);
|
||||
if (files.length === 0) return false;
|
||||
const paths = localCandidates(dt, files);
|
||||
if (!paths) return attachImages(files);
|
||||
if (!isLocalBrowser()) return attachImages(files);
|
||||
const candidates = localCandidates(dt, files);
|
||||
setUpload("uploading");
|
||||
void sameFilesHere(paths, files).then((same) => {
|
||||
if (!same) return void attachImages(files);
|
||||
void (async () => {
|
||||
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
|
||||
paths ??= await fileManagerPaths(files, from);
|
||||
if (!paths) return void attachImages(files);
|
||||
setUpload(null);
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
};
|
||||
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||
@@ -518,7 +526,7 @@ export function PromptEditor({
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
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 {
|
||||
// Only text (a file manager's or an editor's `file://` list, a
|
||||
// selection, a link): what the terminal beneath would paste.
|
||||
@@ -683,17 +691,21 @@ export function PromptEditor({
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||
else if (pastedLocalFiles(e.clipboardData)) e.preventDefault();
|
||||
else {
|
||||
const pasted = Array.from(e.clipboardData?.files ?? []);
|
||||
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:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -18,7 +18,14 @@
|
||||
* (`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
|
||||
* 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)
|
||||
* 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
|
||||
@@ -41,7 +48,9 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
import {
|
||||
@@ -56,7 +65,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { SearchAddon } from "@xterm/addon-search";
|
||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
@@ -69,16 +78,20 @@ import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "..
|
||||
import {
|
||||
claudePastes,
|
||||
copiedFilePaths,
|
||||
isClipboardBitmap,
|
||||
droppedFilePaths,
|
||||
fileManagerPaths,
|
||||
isLocalBrowser,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
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). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -112,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
|
||||
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||
* awaits `loadXterm()` first) sets up synchronously. */
|
||||
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/addon-fit"),
|
||||
import("@xterm/addon-canvas"),
|
||||
import("@xterm/addon-webgl"),
|
||||
import("@xterm/addon-clipboard"),
|
||||
import("@xterm/addon-search"),
|
||||
import("@xterm/addon-web-links"),
|
||||
@@ -123,7 +136,7 @@ const importXterm = async () => {
|
||||
return {
|
||||
Terminal: xterm.Terminal,
|
||||
FitAddon: fit.FitAddon,
|
||||
CanvasAddon: canvas.CanvasAddon,
|
||||
WebglAddon: webgl.WebglAddon,
|
||||
SearchAddon: search.SearchAddon,
|
||||
WebLinksAddon: links.WebLinksAddon,
|
||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||
@@ -147,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
|
||||
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. */
|
||||
const RETRY_MIN_MS = 1500;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
@@ -239,6 +271,12 @@ export const TerminalView = memo(function TerminalView({
|
||||
|
||||
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||
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) => {
|
||||
if (hasText(e)) {
|
||||
@@ -256,7 +294,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
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
|
||||
@@ -277,9 +315,27 @@ export const TerminalView = memo(function TerminalView({
|
||||
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.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);
|
||||
};
|
||||
|
||||
@@ -299,11 +355,15 @@ export const TerminalView = memo(function TerminalView({
|
||||
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||
* server finds the same files there — nothing uploaded —, else the paths
|
||||
* the uploads were saved at. A refused file does not cost the others. */
|
||||
const attachFiles = async (files: File[], local: string[] | null = null) => {
|
||||
* that machine's file manager holds them under (a paste: its clipboard; a
|
||||
* 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;
|
||||
setDrop("uploading");
|
||||
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
||||
paths ??= await fileManagerPaths(files, from);
|
||||
const errors: unknown[] = [];
|
||||
if (!paths) {
|
||||
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
||||
@@ -321,7 +381,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||
if (!errors.length) setDrop(null);
|
||||
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(() => {
|
||||
@@ -329,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
const attach = ({
|
||||
Terminal,
|
||||
FitAddon,
|
||||
CanvasAddon,
|
||||
WebglAddon,
|
||||
SearchAddon,
|
||||
WebLinksAddon,
|
||||
ClipboardWithProvider,
|
||||
@@ -339,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||
// substitutes those specific glyphs from a different face with a
|
||||
// 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
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||
@@ -385,17 +446,30 @@ export const TerminalView = memo(function TerminalView({
|
||||
// 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
|
||||
// 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
|
||||
// context keeps the DOM renderer rather than losing the terminal.
|
||||
let canvas: CanvasAddon | null = null;
|
||||
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
|
||||
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
|
||||
// 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 {
|
||||
canvas = new CanvasAddon();
|
||||
term.loadAddon(canvas);
|
||||
const addon = new WebglAddon();
|
||||
term.loadAddon(addon);
|
||||
gpu = addon;
|
||||
addon.onContextLoss(() => {
|
||||
addon.dispose();
|
||||
if (gpu === addon) gpu = null;
|
||||
});
|
||||
} 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
|
||||
// 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.
|
||||
@@ -482,6 +556,17 @@ export const TerminalView = memo(function TerminalView({
|
||||
term.input("\x1f", true);
|
||||
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" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
@@ -524,7 +609,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
*/
|
||||
const sendSizeNow = (drive = false) => {
|
||||
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 }));
|
||||
};
|
||||
// This browser is the one in use now: back to its own size, and the
|
||||
@@ -561,7 +646,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
// scrollback as torn fragments along the left margin, visible as soon as
|
||||
// you scroll back (issue #33). Happens on every attach: page load,
|
||||
// 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}`);
|
||||
ws = sock;
|
||||
wsRef.current = sock;
|
||||
@@ -656,6 +744,9 @@ export const TerminalView = memo(function TerminalView({
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||
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);
|
||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||
@@ -694,7 +785,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
gpu?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
@@ -939,7 +1030,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
<div
|
||||
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 ${
|
||||
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
|
||||
drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
|
||||
@@ -60,6 +60,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
files: r({ items: [] }),
|
||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||
localPaths: r({ sizes: [1] }),
|
||||
clipboardFiles: r({ files: [] }),
|
||||
selectedFiles: r({ files: [] }),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({
|
||||
@@ -377,6 +379,35 @@ describe("PromptEditor — newlines and images", () => {
|
||||
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 () => {
|
||||
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
||||
const onImagesChange = vi.fn();
|
||||
|
||||
@@ -6,13 +6,24 @@
|
||||
* included) as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||
* 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>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
render,
|
||||
cleanup,
|
||||
act,
|
||||
createEvent,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
const onDataHandlers: Array<(d: string) => void> = [];
|
||||
@@ -27,11 +38,18 @@ const inputMock = vi.fn();
|
||||
let textarea: HTMLTextAreaElement;
|
||||
const dropFileMock = 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", () => ({
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||
nativePaste: () => nativePasteMock(),
|
||||
clipboardFiles: () => clipboardFilesMock(),
|
||||
selectedFiles: () => selectedFilesMock(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -93,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@xterm/addon-canvas", () => ({
|
||||
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
|
||||
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||
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 {
|
||||
@@ -120,6 +154,17 @@ class MockWebSocket {
|
||||
// @ts-expect-error test override
|
||||
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", () => {
|
||||
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||
beforeAll(() => loadXterm());
|
||||
@@ -151,11 +196,51 @@ describe("TerminalView", () => {
|
||||
// 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
|
||||
// leaves that width in the scrollback (issue #33).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
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)", () => {
|
||||
@@ -381,34 +466,75 @@ describe("TerminalView", () => {
|
||||
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
|
||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||
// fallback face with a different advance, so glyphs shove their neighbours
|
||||
// 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" />);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
// 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
|
||||
// unchanged — the stray characters glued to the left edge after scrolling.
|
||||
it("reports its size when the socket opens", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
expect(ws.sent).toContain(
|
||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||
);
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
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 () => {
|
||||
// Webfont load and page zoom both move cols without resizing the element
|
||||
// ResizeObserver watches, which is why onResize is the hook.
|
||||
const restore = withBox();
|
||||
onTestFinished(restore);
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
@@ -590,6 +716,168 @@ describe("TerminalView", () => {
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||
* 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
|
||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
||||
@@ -39,6 +41,8 @@ import {
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||||
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
@@ -100,6 +104,8 @@ export function AppearanceSettings() {
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
const [nativePaste, setNativePaste] = useState(readNativePaste);
|
||||
const { local } = usePermissions();
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
@@ -209,6 +215,18 @@ export function AppearanceSettings() {
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
{local && (
|
||||
<SettingsSwitchRow
|
||||
id="native-paste"
|
||||
label={t("nativePaste.label")}
|
||||
description={t("nativePaste.description")}
|
||||
checked={nativePaste}
|
||||
onChange={(on) => {
|
||||
writeNativePaste(on);
|
||||
setNativePaste(on);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
@@ -24,17 +26,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
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 SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||
|
||||
/**
|
||||
* One kind of git host and the key it takes: no key → a Generate button;
|
||||
* a key → step 1 (copy its public half) followed by the host's own steps.
|
||||
* One SSH key and the steps of the host it is for: no key → a Generate
|
||||
* 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({
|
||||
title,
|
||||
sub,
|
||||
type,
|
||||
label,
|
||||
k,
|
||||
id,
|
||||
writable,
|
||||
busy,
|
||||
onGenerate,
|
||||
onRemove,
|
||||
@@ -114,12 +100,13 @@ function KeyCard({
|
||||
}: {
|
||||
title: string;
|
||||
sub: string;
|
||||
type: "ed25519" | "rsa";
|
||||
/** What names the public key field ("RSA", "ED25519" or the key's name). */
|
||||
label: string;
|
||||
k: Key | undefined;
|
||||
id: GitIdentity;
|
||||
writable: boolean;
|
||||
busy: boolean;
|
||||
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onGenerate: () => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -130,23 +117,19 @@ function KeyCard({
|
||||
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||
</div>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => void onGenerate(type)}
|
||||
>
|
||||
{writable && (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
|
||||
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
{k && id.writable && (
|
||||
{k && writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
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" />
|
||||
{t("gitIdentity.remove")}
|
||||
@@ -165,9 +148,9 @@ function KeyCard({
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
|
||||
value={k.publicKey}
|
||||
rows={type === "rsa" ? 3 : 2}
|
||||
rows={k.type === "rsa" ? 3 : 2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
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() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -229,19 +310,42 @@ export function GitIdentitySettings() {
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !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 k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||
const makeNamed = async () => {
|
||||
if (!newKey) return;
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||
// "Add another key" in a tab makes the next key of that tab's type at once,
|
||||
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
|
||||
// account name of the user's own goes in its step 3.
|
||||
const makeNamed = (type: "ed25519" | "rsa") => {
|
||||
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")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
@@ -304,29 +408,60 @@ export function GitIdentitySettings() {
|
||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||
});
|
||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||
const saveAws = async () => {
|
||||
let user = aws.keyId.trim();
|
||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||
// A login is shown under the key it names; one naming no key (or one gone)
|
||||
// under the default key.
|
||||
const namedIds = new Set(named.map((k) => k.id));
|
||||
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.
|
||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||
if (m?.[1] && m[2]) {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
}
|
||||
const alias = aws.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
// A named key's account goes by the key's name unless another is typed.
|
||||
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||
return run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(aws.key ? { key: aws.key } : {}),
|
||||
...(k?.id ? { key: k.id } : {}),
|
||||
}),
|
||||
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 saveOther = async () => {
|
||||
let host = other.host.trim();
|
||||
@@ -346,7 +481,7 @@ export function GitIdentitySettings() {
|
||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||
};
|
||||
/** 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
|
||||
aria-label={t("gitIdentity.loginKey")}
|
||||
value={value}
|
||||
@@ -355,7 +490,7 @@ export function GitIdentitySettings() {
|
||||
>
|
||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||
{keys
|
||||
.filter((k) => k.id && (!only || k.type === only))
|
||||
.filter((k) => k.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
@@ -396,17 +531,6 @@ export function GitIdentitySettings() {
|
||||
const sshTabs = [
|
||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||
{ 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: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||
: []),
|
||||
@@ -498,228 +622,97 @@ export function GitIdentitySettings() {
|
||||
onSelect={setSshTab}
|
||||
>
|
||||
{activeSshTab === "git" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
<div className="space-y-2">
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
label="ED25519"
|
||||
k={keyOf("ed25519")}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => generate("ed25519")}
|
||||
onRemove={() => removeKey("ed25519")}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
</KeyCard>
|
||||
{namedOf("ed25519").map((k) => {
|
||||
const rows = rowsOf(otherRows, k);
|
||||
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" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{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" })}
|
||||
<div className="space-y-2">
|
||||
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
|
||||
const isNamed = i > 0 && !!k;
|
||||
const rows = rowsOf(awsRows, isNamed ? k : undefined);
|
||||
return (
|
||||
<KeyCard
|
||||
key={k?.id ?? "id_rsa"}
|
||||
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
|
||||
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
|
||||
label={isNamed ? k!.name! : "RSA"}
|
||||
k={k}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
|
||||
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
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>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{loginRows(
|
||||
rows,
|
||||
(r) =>
|
||||
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||
)}
|
||||
{id.writable && (
|
||||
<AwsLoginForm
|
||||
busy={busy}
|
||||
named={isNamed ? k!.name! : undefined}
|
||||
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
{id.writable && keys.some((k) => k.type === "rsa") && (
|
||||
<>
|
||||
{addKey("rsa")}
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSshTab === "other" && (
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @file NineRouterUpdate.tsx
|
||||
* @description **Update 9router** on the AI Proxy page (an admin'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ const VIEW: AiToolsView = {
|
||||
tools: [
|
||||
tool("claude", "Claude Code"),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("goose", "Goose", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
],
|
||||
};
|
||||
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
|
||||
await user.click(within(gemini).getByText("Set default"));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||
// A disabled tool cannot become the default.
|
||||
const grok = screen.getByTestId("ai-tool-grok");
|
||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
||||
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
const goose = screen.getByTestId("ai-tool-goose");
|
||||
expect(within(goose).getByText("Set default")).toBeDisabled();
|
||||
expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
|
||||
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
|
||||
describe("AiToolIcon", () => {
|
||||
it("retries the icon when the same instance shows another tool", async () => {
|
||||
const { AiToolIcon } = await import("../../AiToolIcon");
|
||||
const { container, rerender } = render(
|
||||
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
|
||||
);
|
||||
const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
|
||||
fireEvent.error(container.querySelector("img")!);
|
||||
expect(screen.getByText("O")).toBeInTheDocument();
|
||||
expect(screen.getByText("A")).toBeInTheDocument();
|
||||
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
|
||||
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
|
||||
* 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
|
||||
* the sections never mix; a named key
|
||||
* is made beside the default ones, and a second AWS account is saved under
|
||||
* its own name with the one key it uses.
|
||||
* the sections never mix; "Add another key" makes a named key as one more
|
||||
* card in its own tab, a second AWS account is saved from that card under
|
||||
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -156,7 +156,6 @@ describe("GitIdentitySettings", () => {
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||
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".
|
||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||
@@ -216,9 +215,9 @@ describe("GitIdentitySettings", () => {
|
||||
fingerprint: "SHA256:r",
|
||||
};
|
||||
const PROD = {
|
||||
id: "id_rsa_aws-prod",
|
||||
id: "id_rsa_aws-2",
|
||||
type: "rsa" as const,
|
||||
name: "aws-prod",
|
||||
name: "aws-2",
|
||||
publicKey: "ssh-rsa B prod",
|
||||
fingerprint: "SHA256:p",
|
||||
};
|
||||
@@ -230,45 +229,77 @@ describe("GitIdentitySettings", () => {
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
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(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
);
|
||||
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();
|
||||
await tab("More keys");
|
||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
||||
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
|
||||
// One click makes the key — nothing to type before it.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
|
||||
const prod = await screen.findByRole("region", { name: "aws-2" });
|
||||
expect(within(prod).getByLabelText("Public key aws-2")).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");
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
||||
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" }));
|
||||
// Its own step 3: the account name defaults to the key's name.
|
||||
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA2",
|
||||
alias: "aws-prod",
|
||||
key: "id_rsa_aws-prod",
|
||||
alias: "aws-2",
|
||||
key: "id_rsa_aws-2",
|
||||
});
|
||||
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();
|
||||
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)");
|
||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
|
||||
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -72,15 +72,15 @@ type Row = {
|
||||
};
|
||||
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
|
||||
/**
|
||||
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||
*/
|
||||
function windowsOf(
|
||||
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
||||
export function windowsOf(
|
||||
a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
|
||||
): QuotaWindow[] {
|
||||
const q = a.quota as
|
||||
| {
|
||||
@@ -558,7 +558,7 @@ function AccountCard({
|
||||
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||
* 0% = used up), with when it resets.
|
||||
*/
|
||||
function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @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.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>
|
||||
);
|
||||
}
|
||||
+57
-21
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.test.tsx
|
||||
* @description The Users page's members' tmux card: hidden without one Linux
|
||||
* user per account, lists each running member server with its version and a
|
||||
* badge when it is older than the installed tmux, warns until tmux-member is
|
||||
* installed, and an admin restarts one member — or every idle outdated one,
|
||||
* the working member left out — only with their own password typed; an
|
||||
* analyst sees no restart control.
|
||||
* @file MemberTmux.test.tsx
|
||||
* @description Each member's tmux server as part of the users table
|
||||
* (`MemberTmux.tsx`): the bar above the table is hidden without one Linux
|
||||
* user per account, names the installed tmux and warns until tmux-member is
|
||||
* installed; a row's cell shows the version with a badge when it is older than
|
||||
* the installed tmux, and "—" for a member with no server; an admin restarts
|
||||
* one member — or every idle outdated one, the working member left out — only
|
||||
* with their own password typed; an analyst gets no "Restart idle".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { MemberTmux } from "../../../lib/api";
|
||||
import type { MemberTmux, MemberTmuxRow } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({
|
||||
tmux: vi.fn(),
|
||||
@@ -25,7 +27,41 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
});
|
||||
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 = {
|
||||
available: true,
|
||||
@@ -52,33 +88,33 @@ beforeEach(() => {
|
||||
users.tmux.mockResolvedValue(DATA);
|
||||
});
|
||||
|
||||
describe("MemberTmuxCard", () => {
|
||||
it("renders nothing without one Linux user per account", async () => {
|
||||
describe("MemberTmux", () => {
|
||||
it("shows no bar without one Linux user per account", async () => {
|
||||
users.tmux.mockResolvedValue({ available: false, users: [] });
|
||||
const { container } = render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
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 () => {
|
||||
render(<MemberTmuxCard isAdmin={false} />);
|
||||
expect(await screen.findByText("mia")).toBeInTheDocument();
|
||||
expect(screen.queryByText("bob")).toBeNull();
|
||||
it("marks the servers older than the installed tmux, and a member with none", async () => {
|
||||
render(<Harness isAdmin={false} />);
|
||||
expect(await screen.findByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
||||
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.
|
||||
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("warns until tmux-member is installed", async () => {
|
||||
users.tmux.mockResolvedValue({ ...DATA, matched: false });
|
||||
render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restarts one member only with the admin's password", async () => {
|
||||
users.tmuxRestart.mockResolvedValue({});
|
||||
render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
||||
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
||||
const go = screen.getByRole("button", { name: "Restart" });
|
||||
@@ -90,7 +126,7 @@ describe("MemberTmuxCard", () => {
|
||||
|
||||
it("restarts every idle outdated member at once, the working one left out", async () => {
|
||||
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" }));
|
||||
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||
@@ -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(
|
||||
open: boolean,
|
||||
panelRef: RefObject<HTMLElement>,
|
||||
panelRef: RefObject<HTMLElement | null>,
|
||||
onClose?: () => void,
|
||||
initialFocus?: RefObject<HTMLElement>
|
||||
initialFocus?: RefObject<HTMLElement | null>
|
||||
): void {
|
||||
// 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.
|
||||
|
||||
@@ -63,7 +63,7 @@ type MessageHandler = (msg: WSMessage) => void;
|
||||
* auto-reconnects with capped exponential backoff on close - plus an
|
||||
* immediate reconnect attempt on tab focus/network-online/visibility-change
|
||||
* 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`).
|
||||
* @param onMessage Called with every message parsed from the socket; the
|
||||
* 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 handlersRef = useRef<MessageHandler>(onMessage);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const mountedRef = useRef(true);
|
||||
const reconnectAttempts = useRef(0);
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
// 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
|
||||
// same time. Both would receive every server broadcast, producing
|
||||
// duplicate stream_event deltas (doubled text, duplicate assistant
|
||||
|
||||
@@ -1,37 +1,4 @@
|
||||
{
|
||||
"title": "Administration",
|
||||
"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"
|
||||
}
|
||||
"description": "Accounts and the server AgentHub runs on."
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"download": "Download",
|
||||
"noPreview": "This file type cannot be shown here — download it to open it.",
|
||||
"install": "Install",
|
||||
"installs": "{{count}} installs",
|
||||
"installed": "Installed to {{path}}",
|
||||
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
||||
"rename": "Edit",
|
||||
@@ -183,5 +182,46 @@
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"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",
|
||||
"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",
|
||||
"refreshing": "Refreshing…",
|
||||
"lastUpdated": "Last updated {{time}}",
|
||||
@@ -30,6 +34,7 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
"claudeHome": "Your .claude",
|
||||
"projectClaudeDir": "Project .claude",
|
||||
"projectRoot": "Project root",
|
||||
"claudeJson": "claude.json",
|
||||
|
||||
@@ -30,14 +30,6 @@
|
||||
"since": "waiting {{ago}}"
|
||||
},
|
||||
"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",
|
||||
"heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}",
|
||||
"storage": "Storage",
|
||||
@@ -50,12 +42,7 @@
|
||||
"events": "Events",
|
||||
"score": "Health Score",
|
||||
"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}}",
|
||||
"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",
|
||||
"cacheTip": "Transcript cache hit rate: {{pct}}% (shown, not scored — it restarts empty with the server)",
|
||||
"compact": "Compact",
|
||||
"compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.",
|
||||
"saved": "Saved",
|
||||
@@ -65,9 +52,10 @@
|
||||
"modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%",
|
||||
"noModelData": "No model data",
|
||||
"concurrency": "Concurrency",
|
||||
"intervals_one": "{{count}} interval",
|
||||
"intervals_other": "{{count}} intervals",
|
||||
"intervals_one": "{{count}} agent type",
|
||||
"intervals_other": "{{count}} agent types",
|
||||
"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",
|
||||
"peak": "Peak",
|
||||
"peakTip": "Peak: {{count}} sessions running simultaneously.",
|
||||
@@ -99,11 +87,101 @@
|
||||
"synchronous": "Synchronous",
|
||||
"autoVacuum": "Auto-vacuum",
|
||||
"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": {
|
||||
"title": "Something went wrong",
|
||||
"body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.",
|
||||
"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.nameLabel": "Folder name",
|
||||
"clone.cloning": "Cloning…",
|
||||
"clone.background": "Run in background",
|
||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||
"clone.username": "Username",
|
||||
@@ -656,5 +657,23 @@
|
||||
"fx.readOnly": "Read-only",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -88,5 +88,6 @@
|
||||
"now": "now",
|
||||
"users": "Users",
|
||||
"admin": "Administration",
|
||||
"files": "Files"
|
||||
"files": "Files",
|
||||
"snapshot": "Snapshot"
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"view": "View image"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"tableAgents": "Agents",
|
||||
"tableCost": "Cost",
|
||||
"tableDirectory": "Directory",
|
||||
"tableUser": "User",
|
||||
"tableTool": "AI Tool",
|
||||
"tableActions": "Actions",
|
||||
"noOwner": "No owner",
|
||||
"moreActions": "More actions",
|
||||
"viewDetails": "View details",
|
||||
"defaultName": "Session ",
|
||||
"detail": {
|
||||
"waitingBanner": {
|
||||
|
||||
@@ -759,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.",
|
||||
"add": "Add profile",
|
||||
"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",
|
||||
"remove": "Remove",
|
||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||
@@ -884,7 +907,6 @@
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"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.",
|
||||
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
|
||||
"awsTitle": "AWS CodeCommit",
|
||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||
"stepCopy": "Copy the public key.",
|
||||
@@ -894,7 +916,6 @@
|
||||
"awsRegion": "Region",
|
||||
"awsSave": "Save",
|
||||
"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)",
|
||||
"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",
|
||||
@@ -908,16 +929,14 @@
|
||||
"alias": "Account name",
|
||||
"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>/…",
|
||||
"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>.",
|
||||
"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",
|
||||
"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>.",
|
||||
"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": {
|
||||
"title": "Git credentials",
|
||||
@@ -1037,6 +1056,10 @@
|
||||
"title": "Claude Code",
|
||||
"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": {
|
||||
"title": "Codex",
|
||||
"description": "Codex's own configuration, and what AgentHub reads from it."
|
||||
@@ -1077,6 +1100,12 @@
|
||||
"noMatch": "No setting matches “{{query}}”.",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -1136,6 +1165,10 @@
|
||||
"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": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
||||
|
||||
@@ -36,5 +36,9 @@
|
||||
"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",
|
||||
"autoFixError": "Could not start the Auto fix session",
|
||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up."
|
||||
"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",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"today": "Today",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"storage": "Storage",
|
||||
@@ -314,7 +315,9 @@
|
||||
"doneIdle": "Restarted: {{names}}",
|
||||
"errors": {
|
||||
"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": {
|
||||
"openHome": "Open home in Files",
|
||||
@@ -330,5 +333,33 @@
|
||||
"digit": "A digit",
|
||||
"symbol": "A symbol (e.g. ! - _ @)",
|
||||
"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}}",
|
||||
"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ị",
|
||||
"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"
|
||||
}
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy."
|
||||
}
|
||||
|
||||
@@ -183,5 +183,43 @@
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"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",
|
||||
"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",
|
||||
"refreshing": "Đang làm mới…",
|
||||
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
||||
@@ -30,6 +34,7 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Thư mục gốc",
|
||||
"claudeHome": ".claude của bạn",
|
||||
"projectClaudeDir": "Dự án .claude",
|
||||
"projectRoot": "Thư mục dự án",
|
||||
"claudeJson": "claude.json",
|
||||
|
||||
@@ -30,14 +30,6 @@
|
||||
"since": "đang chờ {{ago}}"
|
||||
},
|
||||
"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",
|
||||
"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ữ",
|
||||
@@ -50,12 +42,7 @@
|
||||
"events": "Sự kiện",
|
||||
"score": "Điểm sức khỏe",
|
||||
"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}}",
|
||||
"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",
|
||||
"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)",
|
||||
"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.",
|
||||
"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}}%",
|
||||
"noModelData": "Chưa có dữ liệu mô hình",
|
||||
"concurrency": "Đồng thời",
|
||||
"intervals_one": "{{count}} khoảng",
|
||||
"intervals_other": "{{count}} khoảng",
|
||||
"intervals_one": "{{count}} loại agent",
|
||||
"intervals_other": "{{count}} loại agent",
|
||||
"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",
|
||||
"peak": "Đỉnh",
|
||||
"peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.",
|
||||
@@ -99,11 +87,101 @@
|
||||
"synchronous": "Đồng bộ",
|
||||
"autoVacuum": "Tự dọn dẹp",
|
||||
"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": {
|
||||
"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.",
|
||||
"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.nameLabel": "Tên thư mục",
|
||||
"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.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||
"clone.username": "Username",
|
||||
@@ -656,5 +657,23 @@
|
||||
"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.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"
|
||||
}
|
||||
|
||||
@@ -88,5 +88,6 @@
|
||||
"now": "bây giờ",
|
||||
"users": "Người dùng",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"view": "Xem ảnh"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"tableAgents": "Agent",
|
||||
"tableCost": "Chi phí",
|
||||
"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 ",
|
||||
"detail": {
|
||||
"waitingBanner": {
|
||||
|
||||
@@ -747,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.",
|
||||
"add": "Thêm profile",
|
||||
"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",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||
@@ -872,7 +895,6 @@
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"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.",
|
||||
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
|
||||
"awsTitle": "AWS CodeCommit",
|
||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||
"stepCopy": "Copy public key.",
|
||||
@@ -882,7 +904,6 @@
|
||||
"awsRegion": "Region",
|
||||
"awsSave": "Lưu",
|
||||
"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)",
|
||||
"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",
|
||||
@@ -896,16 +917,14 @@
|
||||
"alias": "Tên tài khoả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>/…",
|
||||
"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>.",
|
||||
"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",
|
||||
"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>.",
|
||||
"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": {
|
||||
"title": "Git credentials",
|
||||
@@ -1025,6 +1044,10 @@
|
||||
"title": "Claude Code",
|
||||
"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": {
|
||||
"title": "Codex",
|
||||
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
|
||||
@@ -1065,6 +1088,12 @@
|
||||
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -1124,6 +1153,10 @@
|
||||
"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": {
|
||||
"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.",
|
||||
|
||||
@@ -36,5 +36,9 @@
|
||||
"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",
|
||||
"autoFixError": "Không mở được phiên Tự sửa",
|
||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng."
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"role": "Vai trò",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Phiên",
|
||||
"today": "Hôm nay",
|
||||
"last7d": "7 ngày",
|
||||
"last30d": "30 ngày",
|
||||
"storage": "Dung lượng",
|
||||
@@ -314,7 +315,9 @@
|
||||
"doneIdle": "Đã khởi động lại: {{names}}",
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
|
||||
}
|
||||
},
|
||||
"sessionsCount_one": "{{count}} session",
|
||||
"sessionsCount_other": "{{count}} session"
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Mở home trong Files",
|
||||
@@ -330,5 +333,33 @@
|
||||
"digit": "Có chữ số",
|
||||
"symbol": "Có ký tự đặc biệt (vd. ! - _ @)",
|
||||
"username": "Không chứa tên đăng nhập"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"open": "Xem chi tiết {{name}}",
|
||||
"lastLogin": "Đăng nhập gần nhất",
|
||||
"lastSession": "Hoạt động gần nhất",
|
||||
"never": "chưa bao giờ",
|
||||
"spendDaily": "Chi phí · 30 ngày qua",
|
||||
"noUsage": "Không có chi phí trong 30 ngày qua.",
|
||||
"pool": "Account pool & quota",
|
||||
"poolPinned": "ghim vào {{account}}",
|
||||
"poolGroup": "nhóm {{name}}",
|
||||
"poolShared": "pool dùng chung",
|
||||
"poolNone": "Pool của tool này chưa có tài khoản nào.",
|
||||
"poolNotAllowed": "không còn được dùng",
|
||||
"poolTotal7d": "Tổng tài khoản 7 ngày: {{cost}}",
|
||||
"noPanes": "Không có gì đang chạy.",
|
||||
"recentTitle": "Session gần đây",
|
||||
"failed": "Không tải được thông tin người dùng",
|
||||
"activeLabel": "Đang chạy",
|
||||
"activeValue": "{{sessions}} session · {{panes}} pane",
|
||||
"nowTitle": "Đang chạy",
|
||||
"poolTheirs7d": "của họ 7 ngày {{cost}}"
|
||||
},
|
||||
"activity": "Hoạt động",
|
||||
"spend": "Chi phí hôm nay",
|
||||
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
|
||||
"sessionsN": "{{n}} session",
|
||||
"workspacesN": "{{n}} workspace",
|
||||
"tmuxCol": "tmux"
|
||||
}
|
||||
|
||||
@@ -717,6 +717,11 @@
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* xterm 6 draws its own scrollbar (VS Code's scrollable element) instead of
|
||||
the viewport's native one: the same dead affordance, hidden the same way. */
|
||||
.xterm .xterm-scrollable-element > .scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
|
||||
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
|
||||
|
||||
@@ -49,6 +49,16 @@ describe("artifactView", () => {
|
||||
expect(isArtifact("server.js", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("never takes the project's own HTML pages for artifacts", () => {
|
||||
expect(isArtifact("/tmp/out/report.html", true)).toBe(true);
|
||||
expect(isArtifact("/tmp/out/report.html", false)).toBe(false);
|
||||
expect(isArtifact("/repo/index.html", true)).toBe(false);
|
||||
expect(isArtifact("/repo/client/INDEX.HTM", true)).toBe(false);
|
||||
expect(isArtifact("/repo/public/about.html", true)).toBe(false);
|
||||
expect(isArtifact("C:\\repo\\templates\\mail.html", true)).toBe(false);
|
||||
expect(isArtifact("/repo/docs/chart.html", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||
const msgs = [
|
||||
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
||||
|
||||
@@ -31,9 +31,9 @@ describe("git targets", () => {
|
||||
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
|
||||
await api.lanes.git(gitTarget(3, null));
|
||||
const urls = spy.mock.calls.map((c) => String(c[0]));
|
||||
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
|
||||
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
|
||||
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
|
||||
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
|
||||
expect(urls[0]).toMatch(/\/workspaces\/3\/git\/changes\?range=work$/);
|
||||
expect(urls[1]).toMatch(/\/workspaces\/3\/git\/changes\?range=work&session=s-1$/);
|
||||
expect(urls[2]).toMatch(/\/workspaces\/3\/git\/stage\?session=s-1$/);
|
||||
expect(urls[3]).toMatch(/\/workspaces\/3\/git$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @file healthScore.test.ts
|
||||
* @description Tests for lib/healthScore.ts — the System Health score: a calm
|
||||
* machine scores 100, lag (event loop, CPU / memory / I/O pressure) pulls it
|
||||
* down hard, a check without data costs nothing, CPU falls back to load per
|
||||
* core without PSI, the data disk is the one holding the database, and hooks
|
||||
* count only while an agent works.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HostStats } from "../api";
|
||||
import { healthChecks, healthScore, scoreLevel, type HealthInput } from "../healthScore";
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const NOW = Date.parse("2026-10-27T10:00:00Z");
|
||||
|
||||
const host = (over: Partial<HostStats> = {}): HostStats => ({
|
||||
at: "2026-10-27T10:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7", distro: null, arch: "x64" },
|
||||
uptime: 1000,
|
||||
cpu: { model: null, cores: 4, load: [0.5, 0.5, 0.5] },
|
||||
memory: { total: 16 * GB, available: 12 * GB, used: 4 * GB, swapTotal: 0, swapUsed: 0 },
|
||||
disks: [
|
||||
{ device: "/dev/a", mount: "/", type: "ext4", kind: "SSD", total: 100, used: 10, free: 90 },
|
||||
{ device: "/dev/b", mount: "/data", type: "ext4", kind: "HDD", total: 100, used: 99, free: 1 },
|
||||
],
|
||||
homesMount: null,
|
||||
gpus: [],
|
||||
usage: null,
|
||||
pressure: {
|
||||
cpu: { some10: 1, some60: 1, full10: 0 },
|
||||
memory: { some10: 0, some60: 0, full10: 0 },
|
||||
io: { some10: 0, some60: 0, full10: 0 },
|
||||
},
|
||||
...over,
|
||||
});
|
||||
|
||||
const calm = (over: Partial<HealthInput> = {}): HealthInput => ({
|
||||
eventLoop: { p50: 10, p99: 20, max: 40 },
|
||||
heap: { heapUsed: 100, heapTotal: 200, heapLimit: 1000 },
|
||||
hooksInstalled: true,
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T09:59:00Z",
|
||||
working_agents: 1,
|
||||
agents_24h: { completed: 50, error: 0 },
|
||||
},
|
||||
dbPath: "/home/me/.claude/agenthub/dashboard.db",
|
||||
host: host(),
|
||||
now: NOW,
|
||||
...over,
|
||||
});
|
||||
|
||||
const byKey = (input: HealthInput) => new Map(healthChecks(input).map((c) => [c.key, c]));
|
||||
|
||||
describe("healthScore", () => {
|
||||
it("scores a calm machine 100 with every check ok", () => {
|
||||
const checks = healthChecks(calm());
|
||||
expect(healthScore(checks)).toBe(100);
|
||||
expect(checks.every((c) => c.level === "ok")).toBe(true);
|
||||
expect(scoreLevel(100)).toBe("ok");
|
||||
});
|
||||
|
||||
it("drops the score when the machine lags", () => {
|
||||
const lagging = calm({
|
||||
eventLoop: { p50: 50, p99: 1200, max: 2000 },
|
||||
host: host({
|
||||
pressure: {
|
||||
cpu: { some10: 70, some60: 50, full10: 0 },
|
||||
memory: { some10: 0, some60: 0, full10: 0 },
|
||||
io: { some10: 60, some60: 40, full10: 30 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const c = byKey(lagging);
|
||||
expect(c.get("loop")?.level).toBe("bad");
|
||||
expect(c.get("cpu")?.level).toBe("bad");
|
||||
expect(c.get("io")?.level).toBe("bad");
|
||||
const score = healthScore([...c.values()]);
|
||||
expect(score).toBe(100 - 25 - 20 - 20);
|
||||
expect(scoreLevel(score)).toBe("bad");
|
||||
});
|
||||
|
||||
it("charges part of the points between amber and red", () => {
|
||||
const c = byKey(calm({ eventLoop: { p50: 10, p99: 550, max: 600 } })).get("loop");
|
||||
expect(c?.level).toBe("warn");
|
||||
expect(c?.penalty).toBe(12.5);
|
||||
});
|
||||
|
||||
it("costs nothing for what was not measured, and estimates CPU from load without PSI", () => {
|
||||
const c = byKey(
|
||||
calm({
|
||||
eventLoop: undefined,
|
||||
host: host({ pressure: null, cpu: { model: null, cores: 2, load: [5, 1, 1] } }),
|
||||
})
|
||||
);
|
||||
expect(c.get("loop")?.level).toBe("na");
|
||||
expect(c.get("memory")?.level).toBe("na");
|
||||
expect(c.get("cpu")?.detail?.source).toBe("load");
|
||||
expect(c.get("cpu")?.value).toBe(250);
|
||||
expect(c.get("cpu")?.level).toBe("bad");
|
||||
});
|
||||
|
||||
it("reads the disk that holds the database", () => {
|
||||
const c = byKey(calm({ dbPath: "/data/agenthub/dashboard.db" })).get("disk");
|
||||
expect(c?.detail?.mount).toBe("/data");
|
||||
expect(c?.level).toBe("bad");
|
||||
expect(byKey(calm({ dbPath: null })).get("disk")?.detail?.mount).toBe("/");
|
||||
});
|
||||
|
||||
it("counts silent hooks only while an agent works; missing hooks are red", () => {
|
||||
const silent = calm({
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T09:20:00Z",
|
||||
working_agents: 2,
|
||||
agents_24h: { completed: 0, error: 0 },
|
||||
},
|
||||
});
|
||||
expect(byKey(silent).get("hooks")?.level).toBe("bad");
|
||||
const idle = calm({
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T05:00:00Z",
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 0, error: 0 },
|
||||
},
|
||||
});
|
||||
expect(byKey(idle).get("hooks")).toMatchObject({ level: "ok", penalty: 0 });
|
||||
expect(byKey(calm({ hooksInstalled: false })).get("hooks")).toMatchObject({
|
||||
level: "bad",
|
||||
penalty: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("rates agent errors only with enough finished agents", () => {
|
||||
const few = calm({
|
||||
activity: {
|
||||
last_event_at: null,
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 1, error: 2 },
|
||||
},
|
||||
});
|
||||
expect(byKey(few).get("agents")?.level).toBe("na");
|
||||
const many = calm({
|
||||
activity: {
|
||||
last_event_at: null,
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 6, error: 4 },
|
||||
},
|
||||
});
|
||||
expect(byKey(many).get("agents")).toMatchObject({ level: "bad", value: 40 });
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ function stubFetch(body: unknown) {
|
||||
}
|
||||
|
||||
describe("api.lanes.git", () => {
|
||||
it("requests /api/lanes/<id>/git and returns the parsed facts", async () => {
|
||||
it("requests /api/workspaces/<id>/git and returns the parsed facts", async () => {
|
||||
const facts = {
|
||||
available: true,
|
||||
branch: "feat/x",
|
||||
@@ -41,7 +41,7 @@ describe("api.lanes.git", () => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
|
||||
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
|
||||
expect(String(call[0]).endsWith("/api/lanes/3/git")).toBe(true);
|
||||
expect(String(call[0]).endsWith("/api/workspaces/3/git")).toBe(true);
|
||||
expect(call[1]?.method ?? "GET").toBe("GET");
|
||||
expect(result).toEqual(facts);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file workspaceGroups.test.ts
|
||||
* @description Tests for the sidebar's workspace groups store: create, rename,
|
||||
* move (by step or onto another group) and delete (which only ungroups), fold all, assignment, pruning of removed
|
||||
* workspaces, a damaged stored value, and the arrangement the sidebar draws —
|
||||
* groups in order, each keeping the drag order, ungrouped last.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
WORKSPACE_GROUPS_KEY,
|
||||
arrangeByGroup,
|
||||
assignGroup,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
groupOf,
|
||||
moveGroup,
|
||||
moveGroupBefore,
|
||||
pruneWorkspaceGroups,
|
||||
readWorkspaceGroups,
|
||||
renameGroup,
|
||||
setAllFolded,
|
||||
setGroupFolded,
|
||||
} from "../workspaceGroups";
|
||||
|
||||
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
|
||||
|
||||
describe("workspaceGroups", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("starts empty and survives a damaged value", () => {
|
||||
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
|
||||
localStorage.setItem(WORKSPACE_GROUPS_KEY, "{nope");
|
||||
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
|
||||
});
|
||||
|
||||
it("creates a group holding workspaces, renames, folds and deletes it", () => {
|
||||
const id = createGroup(" Frontend ", [1, 2]);
|
||||
let v = readWorkspaceGroups();
|
||||
expect(v.groups).toEqual([{ id, name: "Frontend" }]);
|
||||
expect(groupOf(v, 1)?.id).toBe(id);
|
||||
renameGroup(id, " ");
|
||||
expect(readWorkspaceGroups().groups[0]!.name).toBe("Frontend");
|
||||
renameGroup(id, "Web");
|
||||
setGroupFolded(id, true);
|
||||
v = readWorkspaceGroups();
|
||||
expect(v.groups[0]).toMatchObject({ name: "Web", folded: true });
|
||||
deleteGroup(id);
|
||||
v = readWorkspaceGroups();
|
||||
expect(v.groups).toEqual([]);
|
||||
expect(v.of).toEqual({});
|
||||
});
|
||||
|
||||
it("moves a workspace between groups and out of them", () => {
|
||||
const a = createGroup("A");
|
||||
const b = createGroup("B");
|
||||
assignGroup(5, a);
|
||||
assignGroup(5, b);
|
||||
expect(readWorkspaceGroups().of["5"]).toBe(b);
|
||||
assignGroup(5, "missing");
|
||||
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
|
||||
assignGroup(5, a);
|
||||
assignGroup(5, null);
|
||||
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reorders groups, clamped at both ends", () => {
|
||||
const a = createGroup("A");
|
||||
const b = createGroup("B");
|
||||
moveGroup(a, 1);
|
||||
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
|
||||
moveGroup(a, 5);
|
||||
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
|
||||
});
|
||||
|
||||
it("forgets removed workspaces and ignores ids naming no group", () => {
|
||||
const a = createGroup("A", [1, 2, 3]);
|
||||
pruneWorkspaceGroups([1, 3]);
|
||||
expect(readWorkspaceGroups().of).toEqual({ 1: a, 3: a });
|
||||
localStorage.setItem(
|
||||
WORKSPACE_GROUPS_KEY,
|
||||
JSON.stringify({ groups: [{ id: a, name: "A" }], of: { 1: a, 2: "gone" } })
|
||||
);
|
||||
expect(readWorkspaceGroups().of).toEqual({ 1: a });
|
||||
});
|
||||
|
||||
it("arranges groups in order, keeping the drag order, ungrouped last", () => {
|
||||
const a = createGroup("A", [4, 2]);
|
||||
const b = createGroup("B", [3]);
|
||||
moveGroup(b, -1);
|
||||
const v = readWorkspaceGroups();
|
||||
expect(arrangeByGroup(lanes(1, 2, 3, 4, 5), v).map((l) => l.id)).toEqual([3, 2, 4, 1, 5]);
|
||||
expect(groupOf(v, 2)?.id).toBe(a);
|
||||
});
|
||||
|
||||
it("moves a group before another, or last, and folds every group at once", () => {
|
||||
const a = createGroup("A");
|
||||
const b = createGroup("B");
|
||||
const c = createGroup("C");
|
||||
const ids = () => readWorkspaceGroups().groups.map((g) => g.id);
|
||||
moveGroupBefore(c, a);
|
||||
expect(ids()).toEqual([c, a, b]);
|
||||
moveGroupBefore(c, null);
|
||||
expect(ids()).toEqual([a, b, c]);
|
||||
moveGroupBefore(a, a);
|
||||
moveGroupBefore("nope", b);
|
||||
expect(ids()).toEqual([a, b, c]);
|
||||
setAllFolded(true);
|
||||
expect(readWorkspaceGroups().groups.every((g) => g.folded)).toBe(true);
|
||||
setAllFolded(false);
|
||||
expect(readWorkspaceGroups().groups.some((g) => g.folded)).toBe(false);
|
||||
});
|
||||
});
|
||||
+340
-56
@@ -538,12 +538,91 @@ export type ServerUserUsage = AuthUser & {
|
||||
disabled: boolean;
|
||||
workspaces: number;
|
||||
sessions: number;
|
||||
/** Since the viewer's local midnight (absent from an older server). */
|
||||
today?: { cost: number; tokens: number };
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** Active login sessions (cookies not expired). */
|
||||
logins?: number;
|
||||
lastLoginAt?: string | null;
|
||||
};
|
||||
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
|
||||
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
|
||||
/** One pool account as one user's detail sees it: the account's totals and their share. */
|
||||
export type UserPoolAccount = {
|
||||
name: string;
|
||||
kind: string;
|
||||
email: string | null;
|
||||
expired: boolean;
|
||||
/** Their group/pin lets them run on it (false = they ran on it before). */
|
||||
allowed: boolean;
|
||||
pinned: boolean;
|
||||
quota: PoolAccount["quota"] | CodexPoolAccount["quota"];
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
userSpend5h: number;
|
||||
userSpend7d: number;
|
||||
userTokens5h: number;
|
||||
userTokens7d: number;
|
||||
userRuns7d: number;
|
||||
userLivePanes: number;
|
||||
userLastAt: string | null;
|
||||
};
|
||||
/** `GET /api/users/:id/details` — the Administration page's user detail. */
|
||||
export type UserDetails = {
|
||||
user: AuthUser & { disabled: boolean; logins: number; lastLoginAt: string | null };
|
||||
sessions: { total: number; active: number; firstAt: string | null; lastAt: string | null };
|
||||
usage: {
|
||||
today: { cost: number; tokens: number };
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** 30 local days, the oldest first. */
|
||||
daily: { date: string; cost: number; tokens: number }[];
|
||||
byTool: UsageSlice[];
|
||||
byModel: UsageSlice[];
|
||||
};
|
||||
workspaces: {
|
||||
id: number;
|
||||
title: string;
|
||||
cwd: string;
|
||||
tool: string;
|
||||
kind: string;
|
||||
branch: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
sessions: number;
|
||||
lastActiveAt: string | null;
|
||||
livePanes: number;
|
||||
}[];
|
||||
panes: {
|
||||
id: string;
|
||||
laneId: number;
|
||||
laneTitle: string;
|
||||
laneCwd: string;
|
||||
tool: string;
|
||||
model: string | null;
|
||||
account: string | null;
|
||||
proxy: string | null;
|
||||
sessionId: string | null;
|
||||
startedAt: string;
|
||||
status: string;
|
||||
}[];
|
||||
recentSessions: (Session & { tokens?: number })[];
|
||||
pool: Partial<
|
||||
Record<
|
||||
PoolTool,
|
||||
{
|
||||
pin: string | null;
|
||||
group: { id: number; name: string } | null;
|
||||
accounts: UserPoolAccount[];
|
||||
}
|
||||
>
|
||||
>;
|
||||
audit: AuditEntry[];
|
||||
};
|
||||
/** One member's own tmux server (`GET /api/users/tmux`). */
|
||||
export interface MemberTmuxRow {
|
||||
id: number;
|
||||
@@ -576,6 +655,8 @@ export interface HostUsageGroup {
|
||||
kind: "user" | "host" | "system";
|
||||
userId?: number;
|
||||
username?: string;
|
||||
/** The user's display name, null when none is set (absent from an older server). */
|
||||
displayName?: string | null;
|
||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||
cpu: number;
|
||||
/** Resident memory, bytes (shared pages count in every process). */
|
||||
@@ -585,13 +666,31 @@ export interface HostUsageGroup {
|
||||
/** Busiest processes, by the kernel's short name only. */
|
||||
top: { pid: number; name: string; cpu: number; memory: number }[];
|
||||
}
|
||||
/** One `/proc/pressure/<resource>` reading, in % of the window. */
|
||||
export interface HostPressure {
|
||||
some10: number;
|
||||
some60: number;
|
||||
full10: number;
|
||||
}
|
||||
|
||||
/** `GET /api/host` — hardware, load, disks and per-user usage. */
|
||||
export interface HostStats {
|
||||
at: string;
|
||||
hostname: string;
|
||||
os: { platform: string; release: string; distro: string | null; arch: string };
|
||||
uptime: number;
|
||||
cpu: { model: string | null; cores: number; load: number[] };
|
||||
/** When the machine booted (ISO); absent from an older server. */
|
||||
bootTime?: string;
|
||||
cpu: {
|
||||
model: string | null;
|
||||
/** Logical CPUs (threads). */
|
||||
cores: number;
|
||||
load: number[];
|
||||
/** Physical cores from /proc/cpuinfo; null when it cannot tell. */
|
||||
physicalCores?: number | null;
|
||||
/** Busy % of each logical CPU over the sample; [] off Linux. */
|
||||
perCore?: number[];
|
||||
};
|
||||
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
|
||||
disks: {
|
||||
device: string;
|
||||
@@ -607,6 +706,46 @@ export interface HostStats {
|
||||
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
|
||||
/** null off Linux. */
|
||||
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
|
||||
/** Bytes/s per network interface over the sample (`lo` left out). */
|
||||
network?: { name: string; rx: number; tx: number }[];
|
||||
/** Bytes/s per whole disk over the sample. */
|
||||
diskIo?: { name: string; read: number; write: number }[];
|
||||
/** `/sys/class/hwmon` sensors; [] when none. */
|
||||
temperatures?: { chip: string; label: string; celsius: number }[];
|
||||
/** Linux PSI (`/proc/pressure/*`): % of the last 10 / 60 s tasks stalled on
|
||||
* each resource; null without PSI (or off Linux). */
|
||||
pressure?: {
|
||||
cpu: HostPressure | null;
|
||||
memory: HostPressure | null;
|
||||
io: HostPressure | null;
|
||||
} | null;
|
||||
/** The ten busiest processes machine-wide; `group` is a `HostUsageGroup` key. */
|
||||
topProcesses?: { pid: number; name: string; cpu: number; memory: number; group: string }[];
|
||||
/** The dashboard's own process. */
|
||||
process?: { pid: number; node: string; uptime: number; rss: number; heapUsed: number };
|
||||
}
|
||||
/** One point of `GET /api/host/history`. */
|
||||
export interface HostHistoryPoint {
|
||||
at: string;
|
||||
/** Machine CPU %, null when it could not be sampled. */
|
||||
cpu: number | null;
|
||||
/** Memory used, % of total. */
|
||||
memory: number | null;
|
||||
memoryUsed: number;
|
||||
swapUsed: number;
|
||||
load1: number | null;
|
||||
/** Per user id: CPU % and resident bytes (users with any use only). */
|
||||
users: Record<string, { cpu: number; memory: number }>;
|
||||
}
|
||||
export type HostHistoryRange = "1h" | "6h" | "24h";
|
||||
/** `GET /api/host/history` — kept in memory since the server started. */
|
||||
export interface HostHistory {
|
||||
range: HostHistoryRange;
|
||||
intervalMs: number;
|
||||
/** False when this server keeps no history (local mode, not Linux). */
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
points: HostHistoryPoint[];
|
||||
}
|
||||
|
||||
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
||||
@@ -893,11 +1032,17 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
/**
|
||||
* What a workspace git call works on: the workspace (its id) or, with
|
||||
* `session`, that session's own worktree (`claude --worktree`), or, with
|
||||
* `source`, one of the workspace's extra folders — the server resolves the
|
||||
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
|
||||
* never from a path.
|
||||
* `source`, one of the workspace's extra folders, or with `repo`, a repo
|
||||
* inside the workspace folder — the server resolves the tree from the session
|
||||
* or `lane_sources` row (`?session=` / `?source=`), or accepts `?repo=` only
|
||||
* when its own walk of the folder found that repo; never from a path.
|
||||
*/
|
||||
export type GitTarget = number | { lane: number; session?: string; source?: number };
|
||||
export type GitTarget = number | { lane: number; session?: string; source?: number; repo?: string };
|
||||
|
||||
/** Which extra repo a Changes section reads: an extra folder's `lane_sources`
|
||||
* id (number), or a repo inside the workspace folder, by its path relative
|
||||
* to it (string, from `GET /lanes/:id/repos`). */
|
||||
export type GitSource = number | string;
|
||||
|
||||
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
||||
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
@@ -905,8 +1050,9 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
const lane = typeof target === "number" ? target : target.lane;
|
||||
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
||||
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
||||
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
|
||||
const qs = params.toString();
|
||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
return `/workspaces/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
|
||||
@@ -920,7 +1066,7 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
const qs = new URLSearchParams(q).toString();
|
||||
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
||||
return gitUrl(place, sub, q).replace(/^(\/workspaces\/\d+)\/git/, "$1/fs");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1003,16 +1149,24 @@ export interface FsTrashItem {
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
|
||||
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
||||
/** The target for `laneId`: an extra folder's or an inner repo, else `session`'s worktree, else the workspace. */
|
||||
export function gitTarget(
|
||||
laneId: number,
|
||||
session?: string | null,
|
||||
source?: number | null
|
||||
source?: GitSource | null
|
||||
): GitTarget {
|
||||
if (source) return { lane: laneId, source };
|
||||
if (typeof source === "string" && source) return { lane: laneId, repo: source };
|
||||
if (typeof source === "number" && source) return { lane: laneId, source };
|
||||
return session ? { lane: laneId, session } : laneId;
|
||||
}
|
||||
|
||||
/** A file the dashboard machine's file manager holds, by its own path. */
|
||||
export interface FileManagerFile {
|
||||
path: string;
|
||||
size: number | null;
|
||||
dir: boolean;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
|
||||
auth: {
|
||||
@@ -1073,9 +1227,11 @@ export const api = {
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
},
|
||||
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
|
||||
/** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */
|
||||
host: {
|
||||
stats: () => request<HostStats>("/host"),
|
||||
history: (range: HostHistoryRange = "1h") =>
|
||||
request<HostHistory>(`/host/history?range=${range}`),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -1083,7 +1239,11 @@ export const api = {
|
||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
||||
list: () =>
|
||||
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
/** GET /api/users/:id/details — one account in full (admin/analyst). */
|
||||
details: (id: number) =>
|
||||
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||
* account changes, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
@@ -1992,10 +2152,13 @@ export const api = {
|
||||
purged_agents: number;
|
||||
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
|
||||
gc: (body: { dryRun?: boolean } = {}) =>
|
||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>(
|
||||
"/workspaces/gc",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
},
|
||||
|
||||
// ─────────────────────────────── Workflows API ──────────────────────────────
|
||||
@@ -2141,7 +2304,9 @@ export const api = {
|
||||
*
|
||||
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
|
||||
*/
|
||||
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
|
||||
totalCost: (
|
||||
opts: { lane?: number; date?: string; tool?: string; range?: DateRange; mine?: boolean } = {}
|
||||
) => {
|
||||
// Scope the aggregate to the active data-scope, exactly like the sessions /
|
||||
// stats / analytics endpoints — otherwise switching the Data scope selector
|
||||
// left the Dashboard "total cost" showing the un-narrowed global total.
|
||||
@@ -2151,6 +2316,8 @@ export const api = {
|
||||
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
|
||||
if (opts.date) qs.set("date", opts.date);
|
||||
if (opts.tool) qs.set("tool", opts.tool);
|
||||
// `mine`: only the caller's own sessions, even for an admin/analyst (server mode).
|
||||
if (opts.mine) qs.set("mine", "1");
|
||||
// `range`: total, breakdown and by_tool for those local days only.
|
||||
applyRange(qs, opts.range);
|
||||
applyScope(qs);
|
||||
@@ -2275,6 +2442,13 @@ export const api = {
|
||||
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
|
||||
* script - for the dashboard's "CC Config" explorer/editor pages. */
|
||||
ccConfig: {
|
||||
/**
|
||||
* GET /api/cc-config/target - whose Claude Code config this login manages
|
||||
* here: `own` (server mode, their own account's), `host` (an admin, local
|
||||
* mode, or an analyst's read-only view), `none` (no account: the page is
|
||||
* not theirs).
|
||||
*/
|
||||
target: () => request<CcTarget>("/cc-config/target"),
|
||||
/**
|
||||
* GET /api/cc-config/overview - counts of every artifact kind, for the
|
||||
* explorer's landing page.
|
||||
@@ -3016,7 +3190,9 @@ export const api = {
|
||||
},
|
||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||
artifacts: {
|
||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
||||
list: (
|
||||
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
if (params.kind) qs.set("kind", params.kind);
|
||||
@@ -3092,6 +3268,18 @@ export const api = {
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||
},
|
||||
/** An unowned row: its suggested user (or an admin) takes it. */
|
||||
claim: (id: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
|
||||
/** An unowned row offered to the caller is not theirs. */
|
||||
decline: (id: number) =>
|
||||
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
|
||||
/** Admin: give an unowned row to a user. */
|
||||
assign: (id: number, userId: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId }),
|
||||
}),
|
||||
update: (
|
||||
id: number,
|
||||
body: {
|
||||
@@ -3118,6 +3306,13 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** POST /api/artifacts/:id/install `{update: true}` — an installed skill's
|
||||
* copies replaced by its newest version (the old ones moved aside). */
|
||||
updateInstalled: (id: number) =>
|
||||
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ update: true }),
|
||||
}),
|
||||
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
|
||||
uninstall: (id: number) =>
|
||||
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
|
||||
@@ -3231,6 +3426,12 @@ export const api = {
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
||||
/** GET /api/ai-proxies/9router/update - versions, whether it runs, why an update would be refused, the job. */
|
||||
nineRouterUpdateStatus: (fresh = false) =>
|
||||
request<NineRouterUpdateStatus>(`/ai-proxies/9router/update${fresh ? "?fresh=1" : ""}`),
|
||||
/** POST /api/ai-proxies/9router/update - shut 9router down, update it, start it again hidden (admin). */
|
||||
nineRouterUpdate: () =>
|
||||
request<NineRouterUpdateJob>("/ai-proxies/9router/update", { method: "POST" }),
|
||||
/** POST /api/ai-proxies - save a new profile. */
|
||||
create: (body: AiProxyInput) =>
|
||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||
@@ -3318,6 +3519,27 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
/** POST /api/run/clipboard-files — the files a file manager's Copy put on
|
||||
* the dashboard machine's clipboard, by their own paths (`size` null for a
|
||||
* folder); 403 EREMOTE unless local mode and a loopback browser. */
|
||||
clipboardFiles: () =>
|
||||
request<{ files: FileManagerFile[] }>("/run/clipboard-files", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
/** POST /api/run/selected-files — the files selected in that machine's
|
||||
* file manager (Explorer, Finder): what a drag from it carries. Same
|
||||
* shape and refusal as `clipboardFiles`. */
|
||||
selectedFiles: () =>
|
||||
request<{ files: FileManagerFile[] }>("/run/selected-files", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
/** GET /api/run/native-paste — whether a `claude` pane can read this
|
||||
* machine's clipboard itself (a reader installed, a display reachable);
|
||||
* false for a remote browser and in server mode. `key`: the chord
|
||||
* `claude` reads an image with on that machine (Alt+V on Windows). */
|
||||
nativePaste: () => request<{ native: boolean; key?: "ctrl+v" | "alt+v" }>("/run/native-paste"),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -3726,7 +3948,7 @@ export const api = {
|
||||
* GET /api/lanes — list all lanes with pipeline progress and liveness.
|
||||
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
|
||||
*/
|
||||
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
|
||||
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/workspaces"),
|
||||
/**
|
||||
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
|
||||
* @param body The cwd and optional title. When `cwd` is omitted the server
|
||||
@@ -3742,7 +3964,7 @@ export const api = {
|
||||
/** Only a lane on THIS directory counts, not one on a parent of it. */
|
||||
exact?: boolean;
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
|
||||
request<{ lane: Lane; created: boolean }>("/workspaces/ensure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -3765,7 +3987,7 @@ export const api = {
|
||||
/** This clone's name for {@link lanes.cloneProgress}. */
|
||||
progressToken?: string;
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -3775,7 +3997,7 @@ export const api = {
|
||||
* carrying `progressToken` arrives, and a minute after it ends.
|
||||
*/
|
||||
cloneProgress: (token: string) =>
|
||||
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
|
||||
request<CloneProgressView>(`/workspaces/clone/progress/${encodeURIComponent(token)}`),
|
||||
/**
|
||||
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||
@@ -3783,7 +4005,7 @@ export const api = {
|
||||
*/
|
||||
pipelines: () =>
|
||||
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
|
||||
"/lanes/pipelines"
|
||||
"/workspaces/pipelines"
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
|
||||
@@ -3793,7 +4015,7 @@ export const api = {
|
||||
*/
|
||||
pipelineStats: (laneId?: number | null) =>
|
||||
request<PipelineStats>(
|
||||
`/lanes/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
|
||||
`/workspaces/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/branches — a candidate source repo's local branches.
|
||||
@@ -3805,7 +4027,7 @@ export const api = {
|
||||
*/
|
||||
branches: (repo: string) =>
|
||||
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
|
||||
`/lanes/branches?repo=${encodeURIComponent(repo)}`
|
||||
`/workspaces/branches?repo=${encodeURIComponent(repo)}`
|
||||
),
|
||||
/**
|
||||
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as
|
||||
@@ -3823,7 +4045,7 @@ export const api = {
|
||||
pipeline?: string;
|
||||
tool?: LaneTool;
|
||||
}) =>
|
||||
request<{ lane: Lane }>("/lanes/worktree", {
|
||||
request<{ lane: Lane }>("/workspaces/worktree", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -3858,7 +4080,7 @@ export const api = {
|
||||
file?: boolean;
|
||||
mtime?: number | null;
|
||||
}[];
|
||||
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
||||
}>(`/workspaces/browse${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
/**
|
||||
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
||||
@@ -3866,7 +4088,7 @@ export const api = {
|
||||
* `EFOLDER` (outside the caller's own folder).
|
||||
*/
|
||||
mkdir: (parent: string, name: string) =>
|
||||
request<{ path: string }>("/lanes/browse/mkdir", {
|
||||
request<{ path: string }>("/workspaces/browse/mkdir", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parent, name }),
|
||||
}),
|
||||
@@ -3880,7 +4102,7 @@ export const api = {
|
||||
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
|
||||
*/
|
||||
pickPath: (kind: "dir" | "file", start?: string) =>
|
||||
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
|
||||
request<{ path?: string; cancelled?: boolean }>("/workspaces/pick-path", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, start }),
|
||||
}),
|
||||
@@ -3889,7 +4111,7 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @returns `{ lane }` — the {@link Lane} record.
|
||||
*/
|
||||
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`),
|
||||
get: (id: number) => request<{ lane: Lane }>(`/workspaces/${id}`),
|
||||
/**
|
||||
* GET /api/lanes/:id/git — the lane's working-copy facts.
|
||||
* @param id The lane id.
|
||||
@@ -4023,7 +4245,7 @@ export const api = {
|
||||
*/
|
||||
removeSessionWorktree: (id: number, session: string) =>
|
||||
request<{ removed: true; branch: string | null; branchKept: boolean }>(
|
||||
`/lanes/${id}/session-worktree/remove`,
|
||||
`/workspaces/${id}/session-worktree/remove`,
|
||||
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
|
||||
),
|
||||
gitBranches: (id: GitTarget) =>
|
||||
@@ -4117,7 +4339,7 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @returns {@link LaneRuntime}
|
||||
*/
|
||||
runtime: (id: number) => request<LaneRuntime>(`/lanes/${id}/runtime`),
|
||||
runtime: (id: number) => request<LaneRuntime>(`/workspaces/${id}/runtime`),
|
||||
/**
|
||||
* POST /api/lanes/:id/up — boot the lane's stack through its profile hooks.
|
||||
* Returns 202: the boot runs in the background and streams progress as
|
||||
@@ -4126,7 +4348,7 @@ export const api = {
|
||||
* @param body `build: false` reuses an existing build.
|
||||
*/
|
||||
up: (id: number, body: { build?: boolean } = {}) =>
|
||||
request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, {
|
||||
request<{ ok: true; laneId: number }>(`/workspaces/${id}/up`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -4137,7 +4359,7 @@ export const api = {
|
||||
* @returns The pids stopped and the resulting runtime facts.
|
||||
*/
|
||||
down: (id: number) =>
|
||||
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/lanes/${id}/down`, {
|
||||
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/workspaces/${id}/down`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
@@ -4147,7 +4369,7 @@ export const api = {
|
||||
* @returns `{ lane }` — the created {@link Lane}.
|
||||
*/
|
||||
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
|
||||
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }),
|
||||
request<{ lane: Lane }>("/workspaces", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/lanes/:id — partial update of a lane.
|
||||
* @param id The lane id.
|
||||
@@ -4156,10 +4378,17 @@ export const api = {
|
||||
*/
|
||||
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
||||
* folders and context files, and whether its tool can take them. */
|
||||
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
|
||||
sources: (id: number) => request<LaneSourcesView>(`/workspaces/${id}/sources`),
|
||||
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
|
||||
* (a parent folder of several repos), each a Changes section read with
|
||||
* `?repo=<path>`; `root` = the folder itself is in a repo. */
|
||||
repos: (id: number, refresh = false) =>
|
||||
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
|
||||
`/workspaces/${id}/repos${refresh ? "?refresh=1" : ""}`
|
||||
),
|
||||
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
|
||||
setInstructions: (id: number, instructions: string | null) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
|
||||
request<LaneSourcesView>(`/workspaces/${id}/instructions`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ instructions }),
|
||||
}),
|
||||
@@ -4177,19 +4406,19 @@ export const api = {
|
||||
progressToken?: string;
|
||||
}
|
||||
) =>
|
||||
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
||||
request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
||||
addFolder: (id: number, path: string) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
|
||||
addFile: (id: number, file: File) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: {
|
||||
@@ -4200,11 +4429,14 @@ export const api = {
|
||||
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
||||
* uploaded file's AgentHub copy is deleted. */
|
||||
removeSource: (id: number, sourceId: number) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
||||
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
|
||||
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
|
||||
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
|
||||
update: (id: number, patch: Partial<Lane>) =>
|
||||
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
request<{ lane: Lane }>(`/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/:id/stage — advance the pipeline stage.
|
||||
* @param id The lane id.
|
||||
@@ -4215,14 +4447,17 @@ export const api = {
|
||||
id: number,
|
||||
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
|
||||
) =>
|
||||
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
|
||||
request<{ lane: Lane }>(`/workspaces/${id}/stage`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
|
||||
* action-specific facts that a destructive request must echo as `expect`.
|
||||
*/
|
||||
preflight: (id: number, action: "reset" | "remove" | "purge") =>
|
||||
request<import("./types").LanePreflight>(
|
||||
`/lanes/${id}/preflight?${new URLSearchParams({ action }).toString()}`
|
||||
`/workspaces/${id}/preflight?${new URLSearchParams({ action }).toString()}`
|
||||
),
|
||||
/**
|
||||
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
|
||||
@@ -4234,7 +4469,7 @@ export const api = {
|
||||
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
||||
// `run_id` answers a `start`: the session it opened, which for a split
|
||||
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
|
||||
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
|
||||
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/workspaces/${id}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -4245,7 +4480,7 @@ export const api = {
|
||||
*/
|
||||
features: {
|
||||
list: (id: number) =>
|
||||
request<{ features: import("./types").LaneFeature[] }>(`/lanes/${id}/features`),
|
||||
request<{ features: import("./types").LaneFeature[] }>(`/workspaces/${id}/features`),
|
||||
/**
|
||||
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
|
||||
* @param id The lane id.
|
||||
@@ -4254,7 +4489,7 @@ export const api = {
|
||||
*/
|
||||
show: (id: number, slug: string) =>
|
||||
request<{ feature: import("./types").LaneFeature }>(
|
||||
`/lanes/${id}/features/${encodeURIComponent(slug)}`
|
||||
`/workspaces/${id}/features/${encodeURIComponent(slug)}`
|
||||
),
|
||||
},
|
||||
/**
|
||||
@@ -4264,7 +4499,7 @@ export const api = {
|
||||
*/
|
||||
proof: {
|
||||
list: (id: number) =>
|
||||
request<{ features: import("./types").ProofFeature[] }>(`/lanes/${id}/proof`),
|
||||
request<{ features: import("./types").ProofFeature[] }>(`/workspaces/${id}/proof`),
|
||||
/**
|
||||
* URL for a proof image, ready to use in an <img src>.
|
||||
* @param id The lane id.
|
||||
@@ -4274,7 +4509,7 @@ export const api = {
|
||||
* @returns A URL string pointing to the image.
|
||||
*/
|
||||
imageUrl: (id: number, slug: string, group: string, file: string): string =>
|
||||
`/api/lanes/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
|
||||
`/api/workspaces/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
|
||||
/**
|
||||
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
|
||||
* @param id The lane id.
|
||||
@@ -4283,19 +4518,19 @@ export const api = {
|
||||
* @returns `{ deleted }` — count of deleted images.
|
||||
*/
|
||||
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
|
||||
request<{ deleted: number }>(`/lanes/${id}/proof/${encodeURIComponent(slug)}`, {
|
||||
request<{ deleted: number }>(`/workspaces/${id}/proof/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
mcpSync: (id: number) =>
|
||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
|
||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/workspaces/${id}/mcp/sync`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
profileInit: (id: number, force = false) =>
|
||||
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
|
||||
`/lanes/${id}/profile/init`,
|
||||
`/workspaces/${id}/profile/init`,
|
||||
{ method: "POST", body: JSON.stringify({ force }) }
|
||||
),
|
||||
},
|
||||
@@ -4382,7 +4617,7 @@ export interface Artifact {
|
||||
market: string | null;
|
||||
/** page: the claude.ai link. */
|
||||
url: string | null;
|
||||
files: { path: string; size: number }[];
|
||||
files: { path: string; size: number; mtime?: number }[];
|
||||
size: number | null;
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
@@ -4391,6 +4626,15 @@ export interface Artifact {
|
||||
inLibrary: boolean;
|
||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||
installed?: boolean;
|
||||
/** List rows only: an installed skill whose copy differs from its newest
|
||||
* version (its owner changed it since) — the list offers Update. */
|
||||
updateAvailable?: boolean;
|
||||
/** file: captured when its session stopped, not pinned by hand. */
|
||||
auto?: boolean;
|
||||
/** Server mode: nobody owns it yet. */
|
||||
unowned?: boolean;
|
||||
/** Unowned and offered to the caller to claim or decline. */
|
||||
suggestedForMe?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -4547,6 +4791,13 @@ export interface CcProfileUsage {
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** GET /api/cc-config/target — whose Claude Code config the page manages. */
|
||||
export interface CcTarget {
|
||||
kind: "own" | "host" | "none";
|
||||
readOnly: boolean;
|
||||
claudeHome: string | null;
|
||||
}
|
||||
|
||||
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
|
||||
* var Claude Code reads; a proxy that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
@@ -4742,6 +4993,38 @@ export interface AiProxy {
|
||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||
* own words — truncated server-side, never the key. */
|
||||
/** One step of the 9router update job, in order: shutdown → update → relaunch. */
|
||||
export interface NineRouterUpdateStep {
|
||||
id: "shutdown" | "update" | "relaunch";
|
||||
status: "pending" | "running" | "done" | "failed" | "skipped";
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/** The server's one 9router update job (in memory; null before the first). */
|
||||
export interface NineRouterUpdateJob {
|
||||
status: "running" | "done" | "failed";
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
managedBy: "systemd" | "process" | null;
|
||||
install: "direct" | "sudo";
|
||||
steps: NineRouterUpdateStep[];
|
||||
log: string;
|
||||
}
|
||||
|
||||
/** GET /api/ai-proxies/9router/update. `blocker` is why a start would be refused now. */
|
||||
export interface NineRouterUpdateStatus {
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
running: boolean;
|
||||
managedBy: "systemd" | "process" | null;
|
||||
blocker: { code: string; message: string; command?: string } | null;
|
||||
job: NineRouterUpdateJob | null;
|
||||
}
|
||||
|
||||
export interface AiProxyProbe {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
@@ -5127,8 +5410,9 @@ export interface CcOverview {
|
||||
/** Key filesystem locations the explorer reads from. */
|
||||
roots: {
|
||||
claudeHome: string;
|
||||
projectClaudeDir: string;
|
||||
projectRoot: string;
|
||||
/** Null for a member's own config with no workspace named: no project layer. */
|
||||
projectClaudeDir: string | null;
|
||||
projectRoot: string | null;
|
||||
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
|
||||
claudeJson: string;
|
||||
};
|
||||
|
||||
@@ -43,13 +43,40 @@ export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
|
||||
"image",
|
||||
]);
|
||||
|
||||
/** An edit worth a card in the conversation: a page, a document, a diagram, a
|
||||
* table — and a React component the model wrote whole (a `.tsx` it merely
|
||||
* edited is project code, not an artifact). */
|
||||
/** Folders an app keeps its own pages in: an HTML file under one of them is
|
||||
* the project's code, never a page the model made to show. */
|
||||
const PROJECT_DIRS = new Set([
|
||||
"src",
|
||||
"public",
|
||||
"templates",
|
||||
"views",
|
||||
"pages",
|
||||
"app",
|
||||
"static",
|
||||
"dist",
|
||||
"build",
|
||||
"components",
|
||||
"layouts",
|
||||
]);
|
||||
|
||||
/** An HTML file the project serves itself: an `index.html`, or one under a
|
||||
* folder in {@link PROJECT_DIRS}. */
|
||||
function isProjectPage(path: string): boolean {
|
||||
const parts = path.split(/[\\/]/);
|
||||
const name = (parts.pop() || "").toLowerCase();
|
||||
if (name === "index.html" || name === "index.htm") return true;
|
||||
return parts.some((d) => PROJECT_DIRS.has(d.toLowerCase()));
|
||||
}
|
||||
|
||||
/** An edit worth a card in the conversation: a document, a diagram, a table —
|
||||
* and a page or React component the model wrote whole (a `.tsx` or `.html` it
|
||||
* merely edited, and an HTML page of the project itself, are project code,
|
||||
* not an artifact). */
|
||||
export function isArtifact(path: string, created: boolean): boolean {
|
||||
const k = artifactKind(path);
|
||||
if (k === "react") return created;
|
||||
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
||||
if (k === "html") return created && !isProjectPage(path);
|
||||
return k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
||||
}
|
||||
|
||||
/** A code fence language for the Code tab. */
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file healthScore.ts
|
||||
* @description The Dashboard System Health score: a list of checks, each read
|
||||
* against two thresholds (`warnAt` → amber, `badAt` → red) and costing up to
|
||||
* `max` points as it climbs between them; the score is 100 minus every
|
||||
* deduction (never below 0). Most of the weight is on what makes the machine
|
||||
* or the dashboard feel slow — the event loop's p99 delay
|
||||
* (`/api/settings/info` `server.event_loop`) and Linux pressure stall
|
||||
* information for CPU, memory and disk I/O (`/api/host` `pressure`; the CPU
|
||||
* falls back to load per core without PSI) — then RAM, the data disk, the V8
|
||||
* heap, hooks going silent while agents work, and agent errors in the last
|
||||
* 24 h. A check whose input is missing is `na` and costs nothing. Pure — the
|
||||
* card in `Dashboard.tsx` only renders the result.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { HostStats } from "./api";
|
||||
|
||||
export type CheckLevel = "ok" | "warn" | "bad" | "na";
|
||||
export type CheckKey =
|
||||
"loop" | "cpu" | "memory" | "io" | "ram" | "disk" | "heap" | "hooks" | "agents";
|
||||
|
||||
export interface HealthCheck {
|
||||
key: CheckKey;
|
||||
/** "lag" checks are what a user feels as a slow machine or dashboard. */
|
||||
group: "lag" | "system";
|
||||
/** The measured value in `unit`; null when not measured. */
|
||||
value: number | null;
|
||||
unit: "ms" | "%" | "min";
|
||||
level: CheckLevel;
|
||||
warnAt: number;
|
||||
badAt: number;
|
||||
/** Points this check took off the score. */
|
||||
penalty: number;
|
||||
/** The most it can take. */
|
||||
max: number;
|
||||
/** Extra facts for the row (e.g. which mount, how many agents). */
|
||||
detail?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface HealthInput {
|
||||
/** `server.event_loop` (ms); absent on an older server. */
|
||||
eventLoop?: { p50: number; p99: number; max: number };
|
||||
heap: { heapUsed: number; heapTotal: number; heapLimit?: number };
|
||||
hooksInstalled: boolean;
|
||||
activity?: {
|
||||
last_event_at: string | null;
|
||||
working_agents: number;
|
||||
agents_24h: { completed: number; error: number };
|
||||
};
|
||||
/** The database file, to find the disk it lives on; null for a developer. */
|
||||
dbPath: string | null;
|
||||
host: HostStats | null;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/** Fewest finished agents before an error rate means anything. */
|
||||
const MIN_AGENTS = 5;
|
||||
|
||||
function check(
|
||||
key: CheckKey,
|
||||
group: HealthCheck["group"],
|
||||
value: number | null,
|
||||
unit: HealthCheck["unit"],
|
||||
warnAt: number,
|
||||
badAt: number,
|
||||
max: number,
|
||||
detail?: HealthCheck["detail"]
|
||||
): HealthCheck {
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return { key, group, value: null, unit, level: "na", warnAt, badAt, penalty: 0, max, detail };
|
||||
}
|
||||
const level: CheckLevel = value >= badAt ? "bad" : value >= warnAt ? "warn" : "ok";
|
||||
const t = Math.max(0, Math.min(1, (value - warnAt) / (badAt - warnAt)));
|
||||
return {
|
||||
key,
|
||||
group,
|
||||
value,
|
||||
unit,
|
||||
level,
|
||||
warnAt,
|
||||
badAt,
|
||||
penalty: Math.round(max * t * 10) / 10,
|
||||
max,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
/** The mount holding `path`: the longest mount point that prefixes it, else `/`. */
|
||||
function diskOf(host: HostStats, path: string | null) {
|
||||
const disks = host.disks ?? [];
|
||||
if (path) {
|
||||
const hit = disks
|
||||
.filter((d) => path === d.mount || path.startsWith(d.mount === "/" ? "/" : `${d.mount}/`))
|
||||
.sort((a, b) => b.mount.length - a.mount.length)[0];
|
||||
if (hit) return hit;
|
||||
}
|
||||
return disks.find((d) => d.mount === "/") ?? disks[0] ?? null;
|
||||
}
|
||||
|
||||
export function healthChecks(input: HealthInput): HealthCheck[] {
|
||||
const { host } = input;
|
||||
const now = input.now ?? Date.now();
|
||||
const psi = host?.pressure ?? null;
|
||||
const out: HealthCheck[] = [];
|
||||
|
||||
// ── What lag is made of ──
|
||||
out.push(check("loop", "lag", input.eventLoop?.p99 ?? null, "ms", 100, 1000, 25));
|
||||
if (psi?.cpu) {
|
||||
out.push(check("cpu", "lag", psi.cpu.some10, "%", 20, 60, 20, { source: "psi" }));
|
||||
} else {
|
||||
const load =
|
||||
host && host.cpu.cores > 0 ? ((host.cpu.load[0] ?? 0) / host.cpu.cores) * 100 : null;
|
||||
out.push(check("cpu", "lag", load, "%", 100, 200, 20, { source: "load" }));
|
||||
}
|
||||
out.push(check("memory", "lag", psi?.memory?.some10 ?? null, "%", 5, 25, 25));
|
||||
out.push(check("io", "lag", psi?.io?.some10 ?? null, "%", 15, 50, 20));
|
||||
|
||||
// ── Capacity and the dashboard's own signals ──
|
||||
const mem = host?.memory;
|
||||
out.push(
|
||||
check(
|
||||
"ram",
|
||||
"system",
|
||||
mem && mem.total > 0 ? (mem.used / mem.total) * 100 : null,
|
||||
"%",
|
||||
90,
|
||||
98,
|
||||
10
|
||||
)
|
||||
);
|
||||
const disk = host ? diskOf(host, input.dbPath) : null;
|
||||
out.push(
|
||||
check(
|
||||
"disk",
|
||||
"system",
|
||||
disk && disk.total > 0 ? (disk.used / disk.total) * 100 : null,
|
||||
"%",
|
||||
90,
|
||||
98,
|
||||
15,
|
||||
disk ? { mount: disk.mount } : undefined
|
||||
)
|
||||
);
|
||||
const heapMax = input.heap.heapLimit || input.heap.heapTotal;
|
||||
out.push(
|
||||
check(
|
||||
"heap",
|
||||
"system",
|
||||
heapMax > 0 ? (input.heap.heapUsed / heapMax) * 100 : null,
|
||||
"%",
|
||||
70,
|
||||
90,
|
||||
15
|
||||
)
|
||||
);
|
||||
|
||||
// Hooks: not installed is always red; installed, they matter only while an
|
||||
// agent works — then minutes of silence mean the dashboard went blind.
|
||||
const a = input.activity;
|
||||
if (!input.hooksInstalled) {
|
||||
out.push({
|
||||
key: "hooks",
|
||||
group: "system",
|
||||
value: null,
|
||||
unit: "min",
|
||||
level: "bad",
|
||||
warnAt: 10,
|
||||
badAt: 30,
|
||||
penalty: 30,
|
||||
max: 30,
|
||||
detail: { state: "missing" },
|
||||
});
|
||||
} else if (!a || a.working_agents === 0 || !a.last_event_at) {
|
||||
out.push({
|
||||
...check("hooks", "system", null, "min", 10, 30, 30, { state: "idle" }),
|
||||
level: "ok",
|
||||
});
|
||||
} else {
|
||||
const age = Math.max(0, (now - Date.parse(a.last_event_at)) / 60000);
|
||||
out.push(
|
||||
check("hooks", "system", age, "min", 10, 30, 30, {
|
||||
state: "working",
|
||||
working: a.working_agents,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const done = a ? a.agents_24h.completed + a.agents_24h.error : 0;
|
||||
out.push(
|
||||
check(
|
||||
"agents",
|
||||
"system",
|
||||
a && done >= MIN_AGENTS ? (a.agents_24h.error / done) * 100 : null,
|
||||
"%",
|
||||
10,
|
||||
30,
|
||||
10,
|
||||
a ? { error: a.agents_24h.error, total: done } : undefined
|
||||
)
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function healthScore(checks: HealthCheck[]): number {
|
||||
const lost = checks.reduce((s, c) => s + c.penalty, 0);
|
||||
return Math.max(0, Math.min(100, Math.round((100 - lost) * 10) / 10));
|
||||
}
|
||||
|
||||
/** Green from 90, amber from 70, red below. */
|
||||
export function scoreLevel(score: number): Exclude<CheckLevel, "na"> {
|
||||
return score >= 90 ? "ok" : score >= 70 ? "warn" : "bad";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file innerReposPref — whether the Changes panel lists the git repositories
|
||||
* INSIDE a workspace folder (a parent folder of several repos, one section
|
||||
* each via `GET /api/lanes/:id/repos`) or only the folder's own git. Off by
|
||||
* default ("own"): the panel neither walks the folder nor shows them until
|
||||
* the account turns it on in Settings → Git → Changes panel. One account
|
||||
* preference, `agenthub.changes.innerRepos` (`lib/userPrefs.ts`,
|
||||
* `server/routes/me.js`), read through `useSyncExternalStore` and re-read
|
||||
* when the account's preferences load (`PREFS_EVENT`) or another tab writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { PREFS_EVENT } from "./workspaceGroups";
|
||||
|
||||
export const INNER_REPOS_KEY = "agenthub.changes.innerRepos";
|
||||
export type InnerReposMode = "all" | "own";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function readInnerReposMode(): InnerReposMode {
|
||||
try {
|
||||
return localStorage.getItem(INNER_REPOS_KEY) === "all" ? "all" : "own";
|
||||
} catch {
|
||||
return "own";
|
||||
}
|
||||
}
|
||||
|
||||
export function writeInnerReposMode(mode: InnerReposMode): void {
|
||||
try {
|
||||
// The default is stored as no value, so an account that never chose keeps following it.
|
||||
if (mode === "all") localStorage.setItem(INNER_REPOS_KEY, "all");
|
||||
else localStorage.removeItem(INNER_REPOS_KEY);
|
||||
} catch {
|
||||
/* disabled storage: nothing to remember */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === INNER_REPOS_KEY || e.key === null) l();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(PREFS_EVENT, l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(PREFS_EVENT, l);
|
||||
};
|
||||
}
|
||||
|
||||
export function useInnerReposMode(): InnerReposMode {
|
||||
return useSyncExternalStore(subscribe, readInnerReposMode);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||
* a per-viewer preference, and nothing
|
||||
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
|
||||
* server-side (the CLI's `agenthub workspaces`, the pipeline-stats picker) has an
|
||||
* opinion about it. Ceiling: the order does not follow the user to another
|
||||
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||
* must.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file nativePaste — Settings → Appearance → "Paste images natively": a
|
||||
* per-browser preference in `localStorage` (`agenthub.terminal.nativePaste`,
|
||||
* deliberately NOT synced to the account — it is about this browser's
|
||||
* machine). On (the default), a `claude` pane in a local-mode dashboard opened
|
||||
* from this machine's own browser gets an image paste as `^V`, so `claude`
|
||||
* reads the desktop clipboard itself like a native terminal — nothing is
|
||||
* uploaded or copied. Off, the image is uploaded like a remote browser's. An
|
||||
* SSH tunnel's browser also reaches the dashboard by loopback, and its `^V`
|
||||
* would read the SERVER's clipboard — that is what turning it off is for.
|
||||
* The server must also be able to read its clipboard (`nativePasteWorks`: on
|
||||
* Linux `wl-paste`/`xclip` and a display) — else the `^V` pasted nothing.
|
||||
* On Windows Claude Code binds its image paste to Alt+V, not Ctrl+V
|
||||
* (`nativePasteKey`): a `^V` there was silently ignored.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { api } from "./api";
|
||||
|
||||
export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste";
|
||||
|
||||
/** True unless this browser turned native image paste off. */
|
||||
export function readNativePaste(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(NATIVE_PASTE_KEY) !== "off";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeNativePaste(on: boolean) {
|
||||
try {
|
||||
if (on) window.localStorage.removeItem(NATIVE_PASTE_KEY);
|
||||
else window.localStorage.setItem(NATIVE_PASTE_KEY, "off");
|
||||
} catch {
|
||||
/* storage blocked — the default */
|
||||
}
|
||||
}
|
||||
|
||||
let works: boolean | null = null;
|
||||
let asked: Promise<void> | null = null;
|
||||
// Claude Code's image-paste chord on the dashboard's machine: Ctrl+V, but
|
||||
// Alt+V on Windows, where a ^V reaches no binding and pastes nothing.
|
||||
let key = "\x16";
|
||||
|
||||
/** Whether the dashboard's machine can hand a pane its clipboard, as last
|
||||
* answered — false until it has (an upload then, which always works). The
|
||||
* first call asks the server once per page. */
|
||||
export function nativePasteWorks(): boolean {
|
||||
asked ??= api.run
|
||||
.nativePaste()
|
||||
.then((r) => {
|
||||
works = r.native === true;
|
||||
key = r.key === "alt+v" ? "\x1bv" : "\x16";
|
||||
})
|
||||
.catch(() => void (works = false));
|
||||
return works === true;
|
||||
}
|
||||
|
||||
/** The bytes that make the pane's `claude` read its clipboard: ^V, or ESC v
|
||||
* (Alt+V) on a Windows dashboard. */
|
||||
export function nativePasteKey(): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Test seam: forget the server's answer. */
|
||||
export function __resetNativePaste() {
|
||||
works = null;
|
||||
asked = null;
|
||||
key = "\x16";
|
||||
}
|
||||
@@ -9,8 +9,10 @@
|
||||
* dashboard's own machine attaches by path instead of uploading
|
||||
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
|
||||
* file-manager copy carries —, checked by the server in `sameFilesHere` —
|
||||
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
|
||||
* sent with the prompt; that check is the one call here to the server.
|
||||
* `POST /api/run/local-paths` —, `localAttachment`), and the pasted or
|
||||
* dropped files that machine's file manager holds (`fileManagerPaths`). An
|
||||
* attachment is a path sent with the prompt; those checks are the calls here
|
||||
* to the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -144,6 +146,53 @@ export async function sameFilesHere(paths: string[], files: File[]): Promise<boo
|
||||
}
|
||||
}
|
||||
|
||||
/** A pasted BITMAP (a screenshot), not a copied image FILE: browsers name the
|
||||
* clipboard's picture `image.png`, while a file manager's copy keeps its own
|
||||
* name. Only a bitmap goes to `claude`'s own image paste — a copied file is
|
||||
* pasted by its path, as a native terminal does (`claude` finds no picture
|
||||
* on a clipboard that holds a file) — and a bitmap that comes WITH text (a
|
||||
* spreadsheet's cells, a page's selection) is the text's picture: a
|
||||
* terminal pastes the text. */
|
||||
export function isClipboardBitmap(f: File): boolean {
|
||||
return f.type.startsWith("image/") && f.name === "image.png";
|
||||
}
|
||||
|
||||
/** The pasted or dropped `files` by their own paths on the dashboard's
|
||||
* machine, as its file manager holds them — a paste on its OS clipboard, a
|
||||
* drop as its selection, which is what a drag from it carries
|
||||
* (`POST /api/run/clipboard-files` / `selected-files`): a native terminal's
|
||||
* paths, and the one way past the upload limit. Null when this is not a
|
||||
* loopback browser, on a refusal (server mode), or when any file is not
|
||||
* held under the same name and size (an SSH tunnel's browser pastes
|
||||
* ANOTHER machine's files). Paths come back in `files`' order. */
|
||||
export async function fileManagerPaths(
|
||||
files: File[],
|
||||
from: "paste" | "drop"
|
||||
): Promise<string[] | null> {
|
||||
if (!isLocalBrowser() || files.length === 0) return null;
|
||||
try {
|
||||
const { files: held } = await (from === "paste"
|
||||
? api.run.clipboardFiles()
|
||||
: api.run.selectedFiles());
|
||||
const base = (p: string) =>
|
||||
p
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop() ?? "";
|
||||
const left = [...held];
|
||||
const paths: string[] = [];
|
||||
for (const f of files) {
|
||||
const i = left.findIndex((h) => base(h.path) === f.name && (h.dir || h.size === f.size));
|
||||
if (i < 0) return null;
|
||||
paths.push(left[i]!.path.replace(/(.)[\\/]+$/, "$1"));
|
||||
left.splice(i, 1);
|
||||
}
|
||||
return paths;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The local files a drop or a paste names as `file://` URIs (a file
|
||||
* manager's, an editor's), as absolute paths — or null when it names none,
|
||||
* or anything that is not one. `file:///C:/x` is `C:/x`. */
|
||||
@@ -169,9 +218,11 @@ export function droppedFilePaths(dt: DataTransfer | null | undefined): string[]
|
||||
|
||||
/** A file on this machine, by its absolute path, as an attachment. */
|
||||
export function localAttachment(path: string): Attachment {
|
||||
// A Windows path in `/` form, like `claudeMention` (Windows takes either).
|
||||
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
|
||||
return {
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
|
||||
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
|
||||
name:
|
||||
path
|
||||
.replace(/[\\/]$/, "")
|
||||
|
||||
@@ -375,6 +375,12 @@ export const SETTINGS_INDEX: {
|
||||
},
|
||||
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
|
||||
{ section: "git", titleKey: "worktrees.agenthub", to: "#worktrees" },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "changesPanel.innerRepos",
|
||||
keywords: ["repositories", "nested", "monorepo", "changes"],
|
||||
to: "#changes-panel",
|
||||
},
|
||||
];
|
||||
|
||||
function tier(hay: string | undefined, q: string, exact: number): number {
|
||||
|
||||
+30
-6
@@ -732,10 +732,9 @@ export interface Session {
|
||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||
* journals and hooks), `"antigravity"` (agy's conversation files and
|
||||
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
|
||||
* `"opencode"` (their own session stores, with tokens and subagents) or
|
||||
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
|
||||
* `sessions.tool`. */
|
||||
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens) or `"omp"`
|
||||
* (its own session journals, with tokens and subagents). Rows an older build
|
||||
* wrote may still say `"opencode"` or `"grok"`. Maps to `sessions.tool`. */
|
||||
tool?: string;
|
||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||
* it; null for a session no workspace started.
|
||||
@@ -747,6 +746,15 @@ export interface Session {
|
||||
/** ISO time the user pinned the session to the top of its workspace's
|
||||
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
|
||||
pinned_at?: string | null;
|
||||
/** Server mode, `GET /api/sessions` only: the account owning the session's
|
||||
* workspace (null when no workspace owns it). Absent in local mode. */
|
||||
owner_id?: number | null;
|
||||
/** That owner's username (`users.username`). */
|
||||
owner_name?: string | null;
|
||||
/** That owner's display name (`users.display_name`); null when none is set. */
|
||||
owner_display_name?: string | null;
|
||||
/** When that owner's avatar was last set (`users.avatar_at`); null = none. */
|
||||
owner_avatar_at?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1217,7 +1225,7 @@ export interface CostResult {
|
||||
|
||||
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
|
||||
export interface ToolQuota {
|
||||
id: "claude" | "codex" | "antigravity" | "cursor" | "grok" | "kimi" | "opencode";
|
||||
id: "claude" | "codex" | "antigravity" | "cursor";
|
||||
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
|
||||
* `group` names the model pool a window belongs to (Antigravity's). */
|
||||
windows: Array<{
|
||||
@@ -1458,6 +1466,14 @@ export interface UpdateStatusPayload {
|
||||
plugin_root?: string;
|
||||
finished_at?: string;
|
||||
} | null;
|
||||
/** The step a running plugin-update chain is on (null between runs or on
|
||||
* a server that predates it). Polled while an update runs. */
|
||||
plugin_update_progress?: {
|
||||
step: string;
|
||||
index: number;
|
||||
total: number;
|
||||
started_at: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ───── Terminal run status ─────
|
||||
@@ -2626,7 +2642,7 @@ export interface LaneNode {
|
||||
icon: string;
|
||||
gate: boolean;
|
||||
/** The one command that drives this node — a slash command
|
||||
* (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook ci-gate`),
|
||||
* (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook ci-gate`),
|
||||
* from the pipeline template. Empty string for a node with no single driver
|
||||
* (`done`, or a gate another agent declares). Display text only; the
|
||||
* dashboard never runs it. Optional in the type only because the archived
|
||||
@@ -3228,6 +3244,10 @@ export interface TranscriptContent {
|
||||
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||
* removed (from Claude Code's `structuredPatch`). */
|
||||
diff?: { added: number; removed: number };
|
||||
/** Present on an Agent/Task "tool_result" whose subagent (or one it started)
|
||||
* wrote or edited files: each file with its +/- lines (`counted`: the
|
||||
* results carried them). Read from the subagent's own transcript. */
|
||||
edits?: { path: string; added: number; removed: number; counted: boolean }[];
|
||||
/** Present on an AskUserQuestion "tool_result": question text → the option
|
||||
* the user picked, or what they typed. */
|
||||
answers?: Record<string, string>;
|
||||
@@ -3312,6 +3332,10 @@ export interface TranscriptResult {
|
||||
last_line: number;
|
||||
/** JSONL line number of the first message in this page. */
|
||||
first_line: number;
|
||||
/** Claude only, when the page starts in the middle of a turn: the files
|
||||
* that turn changed before the page's first message — the chat adds them
|
||||
* to the turn's "Edited N files" card. */
|
||||
turn_edits?: { path: string; added: number; removed: number; counted: boolean }[];
|
||||
}
|
||||
|
||||
/** One entry in a session's transcript picker (main agent, a subagent, or a
|
||||
|
||||
@@ -20,6 +20,7 @@ import { api } from "./api";
|
||||
import i18n from "../i18n";
|
||||
import { applyStoredTheme } from "../hooks/useTheme";
|
||||
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
|
||||
import { PREFS_EVENT } from "./workspaceGroups";
|
||||
|
||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||
export const PREF_KEYS = [
|
||||
@@ -44,6 +45,8 @@ export const PREF_KEYS = [
|
||||
"agenthub.tour",
|
||||
"agenthub.workspace.allTabs",
|
||||
"agenthub.markdown.view",
|
||||
"agenthub.workspace.groups",
|
||||
"agenthub.changes.innerRepos",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
@@ -116,6 +119,8 @@ function reapply(prefs: Record<string, string>) {
|
||||
window.dispatchEvent(new Event("sagi:prefs"));
|
||||
applyUiFontSize();
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
// Every other store that reads these keys (the sidebar's workspace groups).
|
||||
window.dispatchEvent(new Event(PREFS_EVENT));
|
||||
const lng = prefs.i18nextLng;
|
||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @file workspaceGroups — the sidebar's user-made workspace groups ("Frontend",
|
||||
* "Client A", …): their names, order and fold state, and which group each
|
||||
* workspace sits in. One JSON value under `agenthub.workspace.groups`, one of
|
||||
* the account preference keys (`lib/userPrefs.ts`, `server/routes/me.js`), so
|
||||
* the groups are kept per ACCOUNT on the server and follow it to any browser,
|
||||
* machine or port, and through updates. Like the drag order, it is a viewer's
|
||||
* arrangement, not a workspace fact: nothing about a lane changes, the CLI and
|
||||
* `GET /api/lanes` know nothing of it, and deleting a group only ungroups its
|
||||
* workspaces. A `useSyncExternalStore` reader cached on the raw string, so
|
||||
* every reader agrees; it re-reads when the account's preferences are loaded
|
||||
* (`PREFS_EVENT`) and when another tab writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const WORKSPACE_GROUPS_KEY = "agenthub.workspace.groups";
|
||||
/** Fired by `userPrefs.hydrate` once an account's values are in localStorage. */
|
||||
export const PREFS_EVENT = "agenthub:prefs";
|
||||
|
||||
export interface WorkspaceGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Folded shut in the sidebar. */
|
||||
folded?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceGroups {
|
||||
/** In display order. */
|
||||
groups: WorkspaceGroup[];
|
||||
/** Lane id → group id. A lane absent here, or naming no group, is ungrouped. */
|
||||
of: Record<string, string>;
|
||||
}
|
||||
|
||||
const EMPTY: WorkspaceGroups = { groups: [], of: {} };
|
||||
export const MAX_GROUP_NAME = 60;
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: { raw: string | null; value: WorkspaceGroups } = { raw: null, value: EMPTY };
|
||||
|
||||
function readRaw(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(WORKSPACE_GROUPS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parse(raw: string | null): WorkspaceGroups {
|
||||
if (!raw) return EMPTY;
|
||||
try {
|
||||
const v = JSON.parse(raw) as Partial<WorkspaceGroups>;
|
||||
const groups = (Array.isArray(v.groups) ? v.groups : []).filter(
|
||||
(g): g is WorkspaceGroup =>
|
||||
!!g && typeof g.id === "string" && !!g.id && typeof g.name === "string"
|
||||
);
|
||||
const ids = new Set(groups.map((g) => g.id));
|
||||
const of: Record<string, string> = {};
|
||||
if (v.of && typeof v.of === "object") {
|
||||
for (const [lane, gid] of Object.entries(v.of)) {
|
||||
if (typeof gid === "string" && ids.has(gid)) of[lane] = gid;
|
||||
}
|
||||
}
|
||||
return { groups, of };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorkspaceGroups(): WorkspaceGroups {
|
||||
const raw = readRaw();
|
||||
if (raw === cache.raw) return cache.value;
|
||||
cache = { raw, value: parse(raw) };
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
function write(next: WorkspaceGroups): void {
|
||||
try {
|
||||
localStorage.setItem(WORKSPACE_GROUPS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* disabled storage: the groups live until the page closes */
|
||||
cache = { raw: JSON.stringify(next), value: next };
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Change the groups through `fn` (given the current value, never mutated). */
|
||||
export function updateWorkspaceGroups(fn: (cur: WorkspaceGroups) => WorkspaceGroups): void {
|
||||
write(fn(readWorkspaceGroups()));
|
||||
}
|
||||
|
||||
const cleanName = (name: string) => name.trim().slice(0, MAX_GROUP_NAME);
|
||||
|
||||
/** A new group at the end, holding `laneIds` (moved out of their old group). Returns its id. */
|
||||
export function createGroup(name: string, laneIds: number[] = []): string {
|
||||
const id = `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
updateWorkspaceGroups((cur) => {
|
||||
const of = { ...cur.of };
|
||||
for (const lane of laneIds) of[lane] = id;
|
||||
return { groups: [...cur.groups, { id, name: cleanName(name) }], of };
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Rename; a blank name keeps the old one. */
|
||||
export function renameGroup(id: string, name: string): void {
|
||||
const clean = cleanName(name);
|
||||
if (!clean) return;
|
||||
updateWorkspaceGroups((cur) => ({
|
||||
...cur,
|
||||
groups: cur.groups.map((g) => (g.id === id ? { ...g, name: clean } : g)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Delete the group; its workspaces become ungrouped (nothing else changes). */
|
||||
export function deleteGroup(id: string): void {
|
||||
updateWorkspaceGroups((cur) => ({
|
||||
groups: cur.groups.filter((g) => g.id !== id),
|
||||
of: Object.fromEntries(Object.entries(cur.of).filter(([, gid]) => gid !== id)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function setGroupFolded(id: string, folded: boolean): void {
|
||||
updateWorkspaceGroups((cur) => ({
|
||||
...cur,
|
||||
groups: cur.groups.map((g) => (g.id === id ? { ...g, folded } : g)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Move the group `by` places (−1 up, +1 down), clamped. */
|
||||
export function moveGroup(id: string, by: number): void {
|
||||
updateWorkspaceGroups((cur) => {
|
||||
const from = cur.groups.findIndex((g) => g.id === id);
|
||||
const to = Math.max(0, Math.min(cur.groups.length - 1, from + by));
|
||||
if (from < 0 || from === to) return cur;
|
||||
const groups = [...cur.groups];
|
||||
groups.splice(to, 0, ...groups.splice(from, 1));
|
||||
return { ...cur, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/** Move the group to just before `beforeId` (a drag onto that header), or to the end (null). */
|
||||
export function moveGroupBefore(id: string, beforeId: string | null): void {
|
||||
updateWorkspaceGroups((cur) => {
|
||||
const moving = cur.groups.find((g) => g.id === id);
|
||||
if (!moving || id === beforeId) return cur;
|
||||
const rest = cur.groups.filter((g) => g.id !== id);
|
||||
const at = beforeId === null ? -1 : rest.findIndex((g) => g.id === beforeId);
|
||||
const groups = at < 0 ? [...rest, moving] : [...rest.slice(0, at), moving, ...rest.slice(at)];
|
||||
if (groups.every((g, i) => g === cur.groups[i])) return cur;
|
||||
return { ...cur, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/** Fold (or unfold) every group at once. */
|
||||
export function setAllFolded(folded: boolean): void {
|
||||
updateWorkspaceGroups((cur) => ({
|
||||
...cur,
|
||||
groups: cur.groups.map((g) => (!!g.folded === folded ? g : { ...g, folded })),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Put a workspace in a group, or (null) take it out of every group. */
|
||||
export function assignGroup(laneId: number, groupId: string | null): void {
|
||||
updateWorkspaceGroups((cur) => {
|
||||
const of = { ...cur.of };
|
||||
if (groupId && cur.groups.some((g) => g.id === groupId)) of[laneId] = groupId;
|
||||
else delete of[laneId];
|
||||
return { ...cur, of };
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget the lanes that no longer exist, so the stored value stays small. */
|
||||
export function pruneWorkspaceGroups(laneIds: number[]): void {
|
||||
const cur = readWorkspaceGroups();
|
||||
const live = new Set(laneIds.map(String));
|
||||
const stale = Object.keys(cur.of).filter((k) => !live.has(k));
|
||||
if (!stale.length) return;
|
||||
write({ ...cur, of: Object.fromEntries(Object.entries(cur.of).filter(([k]) => live.has(k))) });
|
||||
}
|
||||
|
||||
/**
|
||||
* `lanes` (already in the stored drag order) arranged by group: each group's
|
||||
* workspaces in group order, keeping their relative order, then the
|
||||
* ungrouped ones. Stable, never mutates.
|
||||
*/
|
||||
export function arrangeByGroup<T extends { id: number }>(lanes: T[], value: WorkspaceGroups): T[] {
|
||||
const rank = new Map(value.groups.map((g, i) => [g.id, i]));
|
||||
const at = (l: T) => {
|
||||
const gid = value.of[l.id];
|
||||
return gid !== undefined && rank.has(gid) ? rank.get(gid)! : value.groups.length;
|
||||
};
|
||||
return lanes
|
||||
.map((l, i) => ({ l, i }))
|
||||
.sort((a, b) => at(a.l) - at(b.l) || a.i - b.i)
|
||||
.map((x) => x.l);
|
||||
}
|
||||
|
||||
/** The group a workspace is in, or null. */
|
||||
export function groupOf(value: WorkspaceGroups, laneId: number): WorkspaceGroup | null {
|
||||
const gid = value.of[laneId];
|
||||
return (gid && value.groups.find((g) => g.id === gid)) || null;
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === WORKSPACE_GROUPS_KEY || e.key === null) l();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(PREFS_EVENT, l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(PREFS_EVENT, l);
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspaceGroups(): WorkspaceGroups {
|
||||
return useSyncExternalStore(subscribe, readWorkspaceGroups);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file main.tsx
|
||||
* @description Vite client entry point. Bootstraps React 18, loads global styles,
|
||||
* @description Vite client entry point. Bootstraps React 19, loads global styles,
|
||||
* registers the service worker, prefetches the Workspace chunk on a `/workspace`
|
||||
* deep link, and mounts {@link App} into `#root` once the active locale is loaded.
|
||||
*
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
/**
|
||||
* @file Administration.tsx
|
||||
* @description The Administration page (`/admin`, a sidebar item; `/users`
|
||||
* redirects here), server mode only and only for an admin or analyst: a tab
|
||||
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
|
||||
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
|
||||
* (the machine's hardware and each user's share of it, `ServerPanel`).
|
||||
* Local mode and a developer are sent home, like the Users page always did.
|
||||
* redirects here), server mode only and only for an admin or analyst — ONE
|
||||
* page, no tabs: ONE users table holding every per-user account fact (account,
|
||||
* spend, home, tmux; each row expands in
|
||||
* place into that user's detail), the account pool, then the audit log — all
|
||||
* from `Users.tsx`. The machine and each user's live CPU / RAM live on the
|
||||
* Dashboard's System Health tab (`HostHealth.tsx`). Local mode and a developer are sent home, like the
|
||||
* Users page always did.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ServerPanel } from "../components/admin/ServerPanel";
|
||||
import { useHostStats } from "../components/health/HostHealth";
|
||||
import { Users } from "./Users";
|
||||
|
||||
interface AdminTab {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export function Administration() {
|
||||
const { t } = useTranslation("admin");
|
||||
const auth = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
const staff = !!me && me.role !== "developer";
|
||||
// Read once, for each home's share of the homes disk. The machine and each
|
||||
// user's live CPU / RAM are on the Dashboard's System Health tab.
|
||||
const host = useHostStats(staff, 0);
|
||||
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
const tabs: AdminTab[] = [
|
||||
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
|
||||
{ id: "server", label: t("tabs.server"), Icon: Server },
|
||||
];
|
||||
const first = tabs[0]!;
|
||||
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
|
||||
if (!staff) return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
@@ -49,18 +39,7 @@ export function Administration() {
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageTabs
|
||||
ariaLabel={t("title")}
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
|
||||
tabs={tabs}
|
||||
>
|
||||
{active.id === "server" ? (
|
||||
<ServerPanel isAdmin={me.role === "admin"} />
|
||||
) : (
|
||||
<Users embedded />
|
||||
)}
|
||||
</PageTabs>
|
||||
<Users embedded host={host} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||
* It is shown only when the server finds a `9router` binary installed.
|
||||
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||
* down, `npm install -g 9router@latest`, start it again hidden.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||
@@ -54,6 +56,7 @@ import { confirmDialog } from "../lib/confirm";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { NineRouterUpdate } from "../components/settings/NineRouterUpdate";
|
||||
|
||||
const NINE_ROUTER_PORT = 20128;
|
||||
|
||||
@@ -271,6 +274,7 @@ export function AiProxyConfig() {
|
||||
{t("aiProxy.config9router")}
|
||||
</a>
|
||||
)}
|
||||
{nineRouter && isAdmin && <NineRouterUpdate />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user