Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df15789982 | |||
| 272a3e7d6e | |||
| 569b68ec98 | |||
| 631ea96f19 | |||
| dce32b96b7 | |||
| a0cd808456 | |||
| e3745d7504 | |||
| 8546748759 | |||
| cc2ece94c3 | |||
| 6b602c76b8 | |||
| f2f1cd9b6c | |||
| c59e196e41 | |||
| 72aef2426c | |||
| 244f999538 | |||
| 127da516ac | |||
| c0832182f2 | |||
| 9ea7758abb | |||
| f51a6a895e | |||
| 5ccbaf5c23 | |||
| c8bc4a95a9 | |||
| 65a001dad4 |
+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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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
|
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
// warning, not a failure: it costs the run console and `workspace shell`, nothing
|
||||||
// else.
|
// else.
|
||||||
try {
|
try {
|
||||||
const { available, upgrade } = await get("/api/run/tmux");
|
const { available, upgrade } = await get("/api/run/tmux");
|
||||||
@@ -1693,7 +1693,7 @@ function cmdOpen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
|
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
|
||||||
*/
|
*/
|
||||||
async function cmdLanesAdd(args) {
|
async function cmdLanesAdd(args) {
|
||||||
const flag = (name) => {
|
const flag = (name) => {
|
||||||
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
|
|||||||
if (base) body.base = base;
|
if (base) body.base = base;
|
||||||
if (slug) body.slug = slug;
|
if (slug) body.slug = slug;
|
||||||
if (tool) body.tool = tool;
|
if (tool) body.tool = tool;
|
||||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
|
||||||
const deadline = Date.now() + 30_000;
|
const deadline = Date.now() + 30_000;
|
||||||
let lane = provisioning;
|
let lane = provisioning;
|
||||||
while (lane.status === "provisioning" && Date.now() < deadline) {
|
while (lane.status === "provisioning" && Date.now() < deadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
({ lane } = await get(`/api/lanes/${provisioning.id}`));
|
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
|
||||||
}
|
}
|
||||||
if (lane.status === "provisioning") {
|
if (lane.status === "provisioning") {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
|
|||||||
const body = { cwd, title };
|
const body = { cwd, title };
|
||||||
if (pipeline) body.pipeline = pipeline;
|
if (pipeline) body.pipeline = pipeline;
|
||||||
if (tool) body.tool = tool;
|
if (tool) body.tool = tool;
|
||||||
const { lane } = await post("/api/lanes", body);
|
const { lane } = await post("/api/workspaces", body);
|
||||||
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||||
* (owning session died) and cap oversized hook logs, across every lane on
|
* (owning session died) and cap oversized hook logs, across every lane on
|
||||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||||
* action, no HTTP round-trip.
|
* action, no HTTP round-trip.
|
||||||
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
|
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
|
||||||
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||||
* talk to the dashboard server at all.
|
* talk to the dashboard server at all.
|
||||||
*/
|
*/
|
||||||
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
|
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
|
||||||
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||||
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
* resolution (unlike every other `workspace` subcommand) — this is meant to run
|
||||||
* against a bare repo right after `profile init`.
|
* against a bare repo right after `profile init`.
|
||||||
*/
|
*/
|
||||||
async function cmdLanesProfileCheck(args) {
|
async function cmdLanesProfileCheck(args) {
|
||||||
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
|
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
|
||||||
async function cmdLanesLifecycle(action, args) {
|
async function cmdLanesLifecycle(action, args) {
|
||||||
const id = args.find((arg) => !arg.startsWith("--"));
|
const id = args.find((arg) => !arg.startsWith("--"));
|
||||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||||
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
|
||||||
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
||||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||||
if (preflight.database) {
|
if (preflight.database) {
|
||||||
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
const body = { confirm: true, expect };
|
const body = { confirm: true, expect };
|
||||||
if (args.includes("--force")) body.force = true;
|
if (args.includes("--force")) body.force = true;
|
||||||
if (keepDb) body.keepDb = true;
|
if (keepDb) body.keepDb = true;
|
||||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
const error = result.data?.error || {};
|
const error = result.data?.error || {};
|
||||||
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||||
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
|
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
|
||||||
* whether the driving session is still breathing.
|
* whether the driving session is still breathing.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cmdLanes() {
|
async function cmdLanes() {
|
||||||
const { lanes, counts } = await get("/api/lanes");
|
const { lanes, counts } = await get("/api/workspaces");
|
||||||
if (!lanes.length) {
|
if (!lanes.length) {
|
||||||
console.log(
|
console.log(
|
||||||
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
||||||
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
|
|||||||
*
|
*
|
||||||
* Only the FIRST positional counts as an id, and only when it is all digits.
|
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||||
* Scanning the whole argv for a number would swallow flag values —
|
* Scanning the whole argv for a number would swallow flag values —
|
||||||
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
|
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
|
||||||
*
|
*
|
||||||
* @param {string[]} args - Arguments after the subcommand.
|
* @param {string[]} args - Arguments after the subcommand.
|
||||||
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||||
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
|
|||||||
return i > -1 ? args[i + 1] : undefined;
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const explicit = flagValue("lane") || flagValue("workspace");
|
const explicit = flagValue("workspace");
|
||||||
if (explicit) return { laneId: explicit, rest: args };
|
if (explicit) return { laneId: explicit, rest: args };
|
||||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||||
|
|
||||||
const tool = flagValue("tool") || toolFromEnv();
|
const tool = flagValue("tool") || toolFromEnv();
|
||||||
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||||
const { lanes } = await get("/api/lanes");
|
const { lanes } = await get("/api/workspaces");
|
||||||
|
|
||||||
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
||||||
// against the live lane list AND the directory: a pane outlives the lane it
|
// against the live lane list AND the directory: a pane outlives the lane it
|
||||||
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||||
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||||
* lane id.
|
* lane id.
|
||||||
*
|
*
|
||||||
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const { laneId, rest: laneArgs } = resolved;
|
const { laneId, rest: laneArgs } = resolved;
|
||||||
|
|
||||||
if (sub === "runtime") {
|
if (sub === "runtime") {
|
||||||
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const body = {};
|
const body = {};
|
||||||
if (laneArgs.includes("--no-build")) body.build = false;
|
if (laneArgs.includes("--no-build")) body.build = false;
|
||||||
if (laneArgs.includes("--qc")) body.qc = true;
|
if (laneArgs.includes("--qc")) body.qc = true;
|
||||||
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const deadline = Date.now() + 15 * 60 * 1000;
|
const deadline = Date.now() + 15 * 60 * 1000;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
await new Promise((r) => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
|
||||||
if (runtime.healthy) {
|
if (runtime.healthy) {
|
||||||
printRuntime(runtime);
|
printRuntime(runtime);
|
||||||
return;
|
return;
|
||||||
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === "down") {
|
if (sub === "down") {
|
||||||
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(
|
console.error(
|
||||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||||
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
}
|
}
|
||||||
const i = laneArgs.indexOf("--tail");
|
const i = laneArgs.indexOf("--tail");
|
||||||
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||||
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||||
if (!log.available) {
|
if (!log.available) {
|
||||||
console.log("no logs for this workspace yet.");
|
console.log("no logs for this workspace yet.");
|
||||||
return;
|
return;
|
||||||
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await post(
|
const result = await post(
|
||||||
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||||
{ args: laneArgs.slice(1) },
|
{ args: laneArgs.slice(1) },
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
: "merge";
|
: "merge";
|
||||||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||||
const result = await post(
|
const result = await post(
|
||||||
`/api/lanes/${laneId}/sync-base`,
|
`/api/workspaces/${laneId}/sync-base`,
|
||||||
{ mode, branch },
|
{ mode, branch },
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
|
|||||||
|
|
||||||
const resolved = await resolveLaneArg(argsAfterName);
|
const resolved = await resolveLaneArg(argsAfterName);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
|
|||||||
async function cmdFeatureList(args) {
|
async function cmdFeatureList(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
|
||||||
if (!features.length) {
|
if (!features.length) {
|
||||||
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
||||||
return;
|
return;
|
||||||
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
|
|||||||
};
|
};
|
||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
|
||||||
slug,
|
slug,
|
||||||
title: flag("title"),
|
title: flag("title"),
|
||||||
});
|
});
|
||||||
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||||
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||||
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||||
* by anything else in this codebase; a session or hook calls it explicitly. */
|
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||||
async function cmdLanesProofLink(args) {
|
async function cmdLanesProofLink(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
|
||||||
console.log(
|
console.log(
|
||||||
linked
|
linked
|
||||||
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||||
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||||
* profiles. Never run automatically; a session calls it explicitly. */
|
* profiles. Never run automatically; a session calls it explicitly. */
|
||||||
async function cmdLanesMcpSync(args) {
|
async function cmdLanesMcpSync(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes integration <name> [<id>]` — check whether a named
|
/** `agenthub workspace integration <name> [<id>]` — check whether a named
|
||||||
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||||
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||||
async function cmdLanesIntegration(args) {
|
async function cmdLanesIntegration(args) {
|
||||||
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
|
|||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { enabled } = await get(
|
const { enabled } = await get(
|
||||||
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||||
);
|
);
|
||||||
console.log(enabled ? "on" : "off");
|
console.log(enabled ? "on" : "off");
|
||||||
process.exitCode = enabled ? 0 : 1;
|
process.exitCode = enabled ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||||
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
||||||
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
||||||
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
||||||
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
|
|||||||
const resolved = await resolveLaneArg(args.slice(2));
|
const resolved = await resolveLaneArg(args.slice(2));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await get(
|
const result = await get(
|
||||||
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||||
undefined,
|
undefined,
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||||
* lane's pipeline instead of the whole thing.
|
* lane's pipeline instead of the whole thing.
|
||||||
*
|
*
|
||||||
* Every node already carries the one command that drives it (`command`, added
|
* Every node already carries the one command that drives it (`command`, added
|
||||||
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
|
|||||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||||
*
|
*
|
||||||
* - a slash command starts `claude` with it as the prompt;
|
* - a slash command starts `claude` with it as the prompt;
|
||||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
|
||||||
*
|
*
|
||||||
* With no node id it lists the nodes and their commands, which is also the
|
* With no node id it lists the nodes and their commands, which is also the
|
||||||
* error path's answer. `--print` shows the argv and runs nothing.
|
* error path's answer. `--print` shows the argv and runs nothing.
|
||||||
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
|
|||||||
const { flags, positional } = parseArgs(args);
|
const { flags, positional } = parseArgs(args);
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
||||||
// the template does. Merge, so the listing can still mark the current node.
|
// the template does. Merge, so the listing can still mark the current node.
|
||||||
const { pipelines } = await get("/api/lanes/pipelines");
|
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||||
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
||||||
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
||||||
...n,
|
...n,
|
||||||
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||||
* template a lane renders against. `agenthub lanes add --pipeline` could only set
|
* template a lane renders against. `agenthub workspace add --pipeline` could only set
|
||||||
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||||
* was stuck on `default` with no way back to a 16-node template.
|
* was stuck on `default` with no way back to a 16-node template.
|
||||||
*/
|
*/
|
||||||
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
|
|||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
const { pipelines } = await get("/api/lanes/pipelines");
|
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
|
||||||
console.log(
|
console.log(
|
||||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||||
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
|
|||||||
async function cmdLanesShell(args) {
|
async function cmdLanesShell(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
const sessionName = `agenthub-lane-${lane.id}`;
|
const sessionName = `agenthub-lane-${lane.id}`;
|
||||||
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
|
|||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await get(
|
const result = await get(
|
||||||
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||||
undefined,
|
undefined,
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
|
|||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const laneId = resolved.laneId;
|
const laneId = resolved.laneId;
|
||||||
|
|
||||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
|
||||||
stage,
|
stage,
|
||||||
status: flag("status"),
|
status: flag("status"),
|
||||||
evidence: flag("evidence"),
|
evidence: flag("evidence"),
|
||||||
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
|
|||||||
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
||||||
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
||||||
|
|
||||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
|
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
|
||||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||||
|
|
||||||
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
||||||
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
||||||
@@ -3512,8 +3512,8 @@ function replHelp() {
|
|||||||
/** Detailed help for one command (or command prefix): every catalog entry
|
/** Detailed help for one command (or command prefix): every catalog entry
|
||||||
* whose invocation starts with the token. Returns false if none matched. */
|
* whose invocation starts with the token. Returns false if none matched. */
|
||||||
function commandHelp(token) {
|
function commandHelp(token) {
|
||||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
|
||||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
// `help workspaces` finds the `workspace …` rows and vice versa.
|
||||||
const canon = (words) => {
|
const canon = (words) => {
|
||||||
const [head, ...tail] = words.split(" ");
|
const [head, ...tail] = words.split(" ");
|
||||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||||
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
|
|||||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||||
// decides whether to fall back to an offline reader or report the refusal.
|
// decides whether to fall back to an offline reader or report the refusal.
|
||||||
|
|
||||||
/** Command names that are another command under the user-facing word.
|
/** Command names that are another command under the user-facing word. */
|
||||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
const COMMAND_ALIASES = { workspaces: "workspace" };
|
||||||
* `--lane` are the OLD names, still working but deprecated. Removing them is
|
|
||||||
* step 4 of docs/plans/workspace-rename/README.md — not before the third
|
|
||||||
* release after the first one that shipped these aliases. */
|
|
||||||
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
|
|
||||||
const canonicalArgv = (argv) =>
|
const canonicalArgv = (argv) =>
|
||||||
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
||||||
|
|
||||||
/** One stderr line when an old name is typed, so people and agents move over
|
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
|
||||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
|
||||||
function warnOldNames(argv) {
|
* — never "Unknown command" or help, so a script written before says why. */
|
||||||
const old = [];
|
function refuseOldNames(argv) {
|
||||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
if (argv[0] === "lanes") {
|
||||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
console.error(c.red("✖ renamed: use agenthub workspace …"));
|
||||||
if (old.length) {
|
process.exit(1);
|
||||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
}
|
||||||
|
if (argv.includes("--lane")) {
|
||||||
|
console.error(c.red("✖ renamed: use --workspace <id>"));
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
|
|||||||
for (const [name] of items) set.add(name.split(" ")[0]);
|
for (const [name] of items) set.add(name.split(" ")[0]);
|
||||||
}
|
}
|
||||||
set.add("help");
|
set.add("help");
|
||||||
// The old names stay typeable (and completable) for scripts written before.
|
|
||||||
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
||||||
return [...set];
|
return [...set];
|
||||||
})();
|
})();
|
||||||
@@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||||
* points, not here, to avoid recursive REPLs. */
|
* points, not here, to avoid recursive REPLs. */
|
||||||
async function runCommand(argv) {
|
async function runCommand(argv) {
|
||||||
warnOldNames(argv);
|
refuseOldNames(argv);
|
||||||
const [cmd, ...rest] = canonicalArgv(argv);
|
const [cmd, ...rest] = canonicalArgv(argv);
|
||||||
const { flags, positional } = parseArgs(rest);
|
const { flags, positional } = parseArgs(rest);
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
@@ -4571,7 +4569,7 @@ async function runCommand(argv) {
|
|||||||
return cmdReinstallHooks();
|
return cmdReinstallHooks();
|
||||||
case "update-check":
|
case "update-check":
|
||||||
return cmdUpdateCheck();
|
return cmdUpdateCheck();
|
||||||
case "lanes":
|
case "workspace":
|
||||||
if (rest[0] === "add") {
|
if (rest[0] === "add") {
|
||||||
return cmdLanesAdd(rest.slice(1));
|
return cmdLanesAdd(rest.slice(1));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
|||||||
|
|
||||||
The client is a single-page application (SPA) built with modern web technologies:
|
The client is a single-page application (SPA) built with modern web technologies:
|
||||||
|
|
||||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
- **React 19** - Component-based UI with hooks and concurrent features
|
||||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
|
||||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||||
|
|
||||||
@@ -1215,7 +1215,7 @@ graph TB
|
|||||||
|
|
||||||
The client is a production-ready React application with:
|
The client is a production-ready React application with:
|
||||||
|
|
||||||
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
|
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
|
||||||
- ⚡ **Real-time** - WebSocket integration for live updates
|
- ⚡ **Real-time** - WebSocket integration for live updates
|
||||||
- 🧪 **Tested** - Vitest + React Testing Library
|
- 🧪 **Tested** - Vitest + React Testing Library
|
||||||
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
||||||
|
|||||||
Generated
+427
-139
@@ -15,18 +15,18 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/maple-mono": "^5.3.0",
|
"@fontsource/maple-mono": "^5.3.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^1.61.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
@@ -34,17 +34,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
@@ -450,10 +450,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/merge": {
|
"node_modules/@codemirror/merge": {
|
||||||
"version": "6.12.2",
|
"version": "6.12.3",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
|
||||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.0.0",
|
"@codemirror/language": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -478,10 +477,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/view": {
|
"node_modules/@codemirror/view": {
|
||||||
"version": "6.43.14",
|
"version": "6.43.15",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.7.0",
|
"@codemirror/state": "^6.7.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1661,7 +1659,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
||||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5"
|
"@babel/runtime": "^7.12.5"
|
||||||
},
|
},
|
||||||
@@ -2108,30 +2105,21 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
|
||||||
"version": "15.7.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.3.7",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
@@ -2140,6 +2128,326 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||||
@@ -2282,59 +2590,38 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-canvas": {
|
|
||||||
"version": "0.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
|
||||||
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/addon-clipboard": {
|
"node_modules/@xterm/addon-clipboard": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
|
||||||
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
|
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-base64": "^3.7.5"
|
"js-base64": "^3.7.5"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.4.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-fit": {
|
"node_modules/@xterm/addon-fit": {
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-search": {
|
"node_modules/@xterm/addon-search": {
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-web-links": {
|
"node_modules/@xterm/addon-web-links": {
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
|
||||||
"license": "MIT",
|
},
|
||||||
"peerDependencies": {
|
"node_modules/@xterm/addon-webgl": {
|
||||||
"@xterm/xterm": "^5.0.0"
|
"version": "0.19.0",
|
||||||
}
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||||
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "5.5.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
@@ -3706,9 +3993,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.4.2",
|
"version": "26.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -3723,7 +4010,6 @@
|
|||||||
"url": "https://www.locize.com"
|
"url": "https://www.locize.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6 || ^7"
|
||||||
},
|
},
|
||||||
@@ -3734,12 +4020,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-browser-languagedetector": {
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
"version": "8.2.1",
|
"version": "8.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
|
||||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.23.2"
|
"@babel/runtime": "^7.29.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
@@ -3924,6 +4209,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
@@ -4022,18 +4308,6 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"loose-envify": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
@@ -4052,12 +4326,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.474.0",
|
"version": "1.61.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||||
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
|
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
@@ -5378,35 +5657,28 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "19.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "19.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"scheduler": "^0.28.0"
|
||||||
"scheduler": "^0.23.2"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.1"
|
"react": "^19.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.16",
|
"version": "17.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.7",
|
"@babel/runtime": "^7.29.7",
|
||||||
"html-parse-stringify": "^4.0.1",
|
"html-parse-stringify": "^4.0.1",
|
||||||
@@ -5755,13 +6027,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
@@ -5774,9 +6042,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
|
||||||
},
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -6183,17 +6451,37 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc"
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
|
|||||||
+13
-13
@@ -18,18 +18,18 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/maple-mono": "^5.3.0",
|
"@fontsource/maple-mono": "^5.3.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^1.61.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
@@ -37,17 +37,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 465 KiB |
@@ -49,7 +49,7 @@
|
|||||||
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
|
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
|
||||||
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
|
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
|
||||||
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
|
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
|
||||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
|
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(792,300)">
|
<g transform="translate(792,300)">
|
||||||
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>
|
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@@ -24,10 +24,18 @@ export function DocumentTitle() {
|
|||||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||||
} else if (path.startsWith("/sessions")) {
|
} else if (path.startsWith("/sessions")) {
|
||||||
title = t("sessions");
|
title = t("sessions");
|
||||||
|
} else if (path.startsWith("/snapshot/")) {
|
||||||
|
title = t("snapshot");
|
||||||
} else if (path.startsWith("/workspace")) {
|
} else if (path.startsWith("/workspace")) {
|
||||||
title = t("workspace");
|
title = t("workspace");
|
||||||
} else if (path.startsWith("/settings")) {
|
} else if (path.startsWith("/settings")) {
|
||||||
title = t("settings");
|
title = t("settings");
|
||||||
|
} else if (path.startsWith("/artifacts")) {
|
||||||
|
title = t("artifacts");
|
||||||
|
} else if (path.startsWith("/files")) {
|
||||||
|
title = t("files");
|
||||||
|
} else if (path.startsWith("/admin")) {
|
||||||
|
title = t("admin");
|
||||||
} else if (path !== "/") {
|
} else if (path !== "/") {
|
||||||
title = t("notFound");
|
title = t("notFound");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
|||||||
moment = null,
|
moment = null,
|
||||||
}: SagiAvatarProps) {
|
}: SagiAvatarProps) {
|
||||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||||
const rafRef = useRef<number>();
|
const rafRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||||
|
|||||||
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||||
|
|
||||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const lastBubbleAt = useRef(0);
|
const lastBubbleAt = useRef(0);
|
||||||
const burst = useRef<Burst | null>(null);
|
const burst = useRef<Burst | null>(null);
|
||||||
const momentKey = useRef(0);
|
const momentKey = useRef(0);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
|
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
||||||
|
* user's OWN spend even for an admin — and, on the
|
||||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||||
@@ -50,9 +51,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
|||||||
codex: "Codex",
|
codex: "Codex",
|
||||||
antigravity: "Antigravity",
|
antigravity: "Antigravity",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
grok: "Grok",
|
|
||||||
kimi: "Kimi",
|
|
||||||
opencode: "OpenCode Go",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function localDate(d = new Date()): string {
|
function localDate(d = new Date()): string {
|
||||||
@@ -281,7 +279,7 @@ function QuotaChips() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Leaving the chips closes the panel only after a beat, and entering the
|
// Leaving the chips closes the panel only after a beat, and entering the
|
||||||
// panel cancels that: the pointer crosses the gap to read it.
|
// panel cancels that: the pointer crosses the gap to read it.
|
||||||
const closeTimer = useRef<number>();
|
const closeTimer = useRef<number | undefined>(undefined);
|
||||||
const show = () => {
|
const show = () => {
|
||||||
window.clearTimeout(closeTimer.current);
|
window.clearTimeout(closeTimer.current);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@@ -406,7 +404,7 @@ export function StatusBar({
|
|||||||
const gen = costGen.current;
|
const gen = costGen.current;
|
||||||
const live = () => gen === costGen.current;
|
const live = () => gen === costGen.current;
|
||||||
api.pricing
|
api.pricing
|
||||||
.totalCost({ date: localDate() })
|
.totalCost({ date: localDate(), mine: true })
|
||||||
.then((r) => live() && setToday(r))
|
.then((r) => live() && setToday(r))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.budgets
|
api.budgets
|
||||||
|
|||||||
@@ -260,9 +260,8 @@ export function UpdateNotifier() {
|
|||||||
// poll that reloads the page keep running, and a failure re-opens it.
|
// poll that reloads the page keep running, and a failure re-opens it.
|
||||||
const closeForNow = useCallback(() => {
|
const closeForNow = useCallback(() => {
|
||||||
if (!dismissKey) return;
|
if (!dismissKey) return;
|
||||||
if (applying || restarting) toast.info(t("backgroundNote"));
|
|
||||||
setClosedKey(dismissKey);
|
setClosedKey(dismissKey);
|
||||||
}, [applying, restarting, dismissKey, t]);
|
}, [dismissKey]);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
if (busy || !dismissKey) return;
|
if (busy || !dismissKey) return;
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||||
|
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
|
||||||
|
* server like the other preferences) arrange the list under foldable headers —
|
||||||
|
* each group in its order, then "Ungrouped" — inside each owner group when
|
||||||
|
* there are owner groups. A workspace joins a group from its menu, by a drop
|
||||||
|
* on the group's header, by a drag that lands it below a row of that
|
||||||
|
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
|
||||||
|
* another to reorder the groups, and its menu renames, moves, folds every
|
||||||
|
* group, adds a new workspace straight into it or deletes it (its
|
||||||
|
* workspaces stay, ungrouped). A folded header still shows the worst state
|
||||||
|
* of its workspaces (needs you, error, running) and marks the selected one.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -30,7 +40,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
|
||||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
@@ -46,7 +56,31 @@ import {
|
|||||||
writeLaneOrder,
|
writeLaneOrder,
|
||||||
} from "../lib/laneOrderStorage";
|
} from "../lib/laneOrderStorage";
|
||||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
import {
|
||||||
|
arrangeByGroup,
|
||||||
|
assignGroup,
|
||||||
|
createGroup,
|
||||||
|
deleteGroup,
|
||||||
|
groupOf,
|
||||||
|
moveGroup,
|
||||||
|
moveGroupBefore,
|
||||||
|
pruneWorkspaceGroups,
|
||||||
|
renameGroup,
|
||||||
|
setAllFolded,
|
||||||
|
setGroupFolded,
|
||||||
|
useWorkspaceGroups,
|
||||||
|
type WorkspaceGroup,
|
||||||
|
} from "../lib/workspaceGroups";
|
||||||
|
import { confirmDialog } from "../lib/confirm";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
|
||||||
|
import {
|
||||||
|
LANE_DRAG_TYPE,
|
||||||
|
WorkspaceRow,
|
||||||
|
isLaneDrag,
|
||||||
|
workspaceDot,
|
||||||
|
workspaceDotLabel,
|
||||||
|
workspaceName,
|
||||||
|
} from "./lanes/WorkspaceRow";
|
||||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||||
import { UserAvatar } from "./users/UserAvatar";
|
import { UserAvatar } from "./users/UserAvatar";
|
||||||
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
|
|||||||
);
|
);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||||
|
// "New workspace in this group": the group the added workspace joins.
|
||||||
|
const [addToGroup, setAddToGroup] = useState<string | null>(null);
|
||||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||||
const [orderTick, setOrderTick] = useState(0);
|
const [orderTick, setOrderTick] = useState(0);
|
||||||
|
const groups = useWorkspaceGroups();
|
||||||
|
const hasGroups = groups.groups.length > 0;
|
||||||
|
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
|
||||||
|
const [groupMenu, setGroupMenu] = useState<{
|
||||||
|
group: WorkspaceGroup;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
// A removed workspace leaves the stored groups (they hold ids only).
|
||||||
|
const laneIdsKey = lanes.map((l) => l.id).join(",");
|
||||||
|
useEffect(() => {
|
||||||
|
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
|
||||||
|
}, [laneIdsKey]);
|
||||||
|
|
||||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||||
// status or session moves, so that is when the live-pane list is re-read;
|
// status or session moves, so that is when the live-pane list is re-read;
|
||||||
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
|
|||||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAdd = (sourceRepo?: string) => {
|
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
|
||||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||||
|
setAddToGroup(groupId);
|
||||||
setAddOpen(true);
|
setAddOpen(true);
|
||||||
api.run
|
api.run
|
||||||
.cwds()
|
.cwds()
|
||||||
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
|
|||||||
const toolFilter =
|
const toolFilter =
|
||||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const list = sortByStoredOrder(lanes, order).filter(
|
const list = arrangeByGroup(
|
||||||
(l) => !toolFilter || l.tool === toolFilter
|
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||||
|
groups
|
||||||
);
|
);
|
||||||
if (!grouped) return list;
|
if (!grouped) return list;
|
||||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||||
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
|
|||||||
return [...list].sort(
|
return [...list].sort(
|
||||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||||
);
|
);
|
||||||
}, [lanes, order, toolFilter, grouped, meId]);
|
}, [lanes, order, toolFilter, grouped, meId, groups]);
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const nameHit = (l: Lane) =>
|
const nameHit = (l: Lane) =>
|
||||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||||
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
|
|||||||
setOrderTick((n) => n + 1);
|
setOrderTick((n) => n + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
|
||||||
|
// screen and joins the next group instead (the arrangement would otherwise
|
||||||
|
// put it straight back): last of the group above, first of the one below —
|
||||||
|
// opened if folded, so the focused row stays shown. The shown order is
|
||||||
|
// written as it stands, so the store agrees. Never across an owner's section.
|
||||||
|
const nudge = (from: number, to: number) => {
|
||||||
|
const lane = sorted[from];
|
||||||
|
const next = sorted[to];
|
||||||
|
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
|
||||||
|
const target = groupOf(groups, next.id);
|
||||||
|
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
|
||||||
|
assignGroup(lane.id, target?.id ?? null);
|
||||||
|
if (target?.folded) setGroupFolded(target.id, false);
|
||||||
|
reorder((ids) => [...ids]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reorder((ids) => moveLane(ids, from, to));
|
||||||
|
};
|
||||||
|
|
||||||
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
||||||
// workspace's session list, the space above or below a row — lands too.
|
// workspace's session list, the space above or below a row — lands too.
|
||||||
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
||||||
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
|
|||||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||||
// Number("") is 0: an empty payload must not move the first row.
|
// Number("") is 0: an empty payload must not move the first row.
|
||||||
const from = raw === "" ? NaN : Number(raw);
|
const from = raw === "" ? NaN : Number(raw);
|
||||||
|
if (!Number.isInteger(from)) return;
|
||||||
|
const lane = sorted[from];
|
||||||
|
// Dropped on a group's header: into that group, at its top.
|
||||||
|
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
|
||||||
|
if (header && lane) {
|
||||||
|
const gid = header.dataset.groupDrop || null;
|
||||||
|
assignGroup(lane.id, gid);
|
||||||
|
const first = sorted.findIndex(
|
||||||
|
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
|
||||||
|
);
|
||||||
|
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const before = dropIndexAt(e.clientY);
|
const before = dropIndexAt(e.clientY);
|
||||||
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
|
// Elsewhere it joins the group of the row it now sits under (the line
|
||||||
|
// drawn above a header closes the section before it).
|
||||||
|
if (lane && hasGroups) {
|
||||||
|
const rest = sorted.filter((l) => l.id !== lane.id);
|
||||||
|
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
|
||||||
|
const neighbour = rest[at - 1] ?? rest[0];
|
||||||
|
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
|
||||||
|
}
|
||||||
|
reorder((ids) => moveBefore(ids, from, before));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A group no workspace is in yet still shows (to be renamed, or dropped on),
|
||||||
|
// just above the ungrouped ones; a search hides it.
|
||||||
|
const emptyGroups = q
|
||||||
|
? []
|
||||||
|
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
|
||||||
|
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
|
||||||
|
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
|
||||||
|
<GroupHeader
|
||||||
|
key={g?.id ?? "none"}
|
||||||
|
group={g}
|
||||||
|
count={members.length}
|
||||||
|
// What a folded group hides: the most urgent of its workspaces, and
|
||||||
|
// whether the open one is among them.
|
||||||
|
worst={worstOf(members)}
|
||||||
|
holdsSelected={members.some((l) => l.id === selected.lane)}
|
||||||
|
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
|
||||||
|
folded={!!g?.folded && !q}
|
||||||
|
renaming={!!g && renamingGroup === g.id}
|
||||||
|
first={first}
|
||||||
|
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
|
||||||
|
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
|
||||||
|
onRenameEnd={(name) => {
|
||||||
|
setRenamingGroup(null);
|
||||||
|
if (g && name !== null) renameGroup(g.id, name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const newGroup = (laneIds: number[] = []) => {
|
||||||
|
const id = createGroup(t("tree.groupDefaultName"), laneIds);
|
||||||
|
setRenamingGroup(id);
|
||||||
|
};
|
||||||
|
const removeGroup = async (g: WorkspaceGroup) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("tree.groupDelete"),
|
||||||
|
message: t("tree.groupDeleteConfirm", { name: g.name }),
|
||||||
|
confirmLabel: t("tree.groupDelete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
deleteGroup(g.id);
|
||||||
|
};
|
||||||
|
|
||||||
const rename = async (lane: Lane, title: string | null) => {
|
const rename = async (lane: Lane, title: string | null) => {
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
if (title === null || title === (lane.title || "")) return;
|
if (title === null || title === (lane.title || "")) return;
|
||||||
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
|
|||||||
>
|
>
|
||||||
<Search className="h-3.5 w-3.5" />
|
<Search className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="workspace-tree-new-group"
|
||||||
|
onClick={() => newGroup()}
|
||||||
|
aria-label={t("tree.newGroup")}
|
||||||
|
title={t("tree.newGroup")}
|
||||||
|
className={headBtn}
|
||||||
|
>
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="workspace-tree-add"
|
data-testid="workspace-tree-add"
|
||||||
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
|
|||||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||||
|
// The user's own group, inside the owner's.
|
||||||
|
const ug = groupOf(groups, lane.id);
|
||||||
|
const ugKey = ug?.id ?? "";
|
||||||
|
const sameSection = (l: Lane) =>
|
||||||
|
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
|
||||||
|
const prev = sorted[i - 1];
|
||||||
|
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
|
||||||
|
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
|
||||||
|
const sectionFolded = !!ug?.folded && !q;
|
||||||
|
const hiddenRow = folded || sectionFolded;
|
||||||
return (
|
return (
|
||||||
<div key={lane.id} className="relative">
|
<div key={lane.id} className="relative">
|
||||||
{groupStart && groupShown && (
|
{groupStart && groupShown && (
|
||||||
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
|
||||||
|
{sectionStart &&
|
||||||
|
sectionShown &&
|
||||||
|
!folded &&
|
||||||
|
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
|
||||||
{dropAt === i && <DropLine />}
|
{dropAt === i && <DropLine />}
|
||||||
{visible(lane) && !folded && (
|
{visible(lane) && !hiddenRow && (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
lane={lane}
|
lane={lane}
|
||||||
index={i}
|
index={i}
|
||||||
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
|
|||||||
}}
|
}}
|
||||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||||
onNewSession={() => select(lane.id)}
|
onNewSession={() => select(lane.id)}
|
||||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
onReorder={nudge}
|
||||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||||
onRenameStart={() => setRenamingId(lane.id)}
|
onRenameStart={() => setRenamingId(lane.id)}
|
||||||
onRenameEnd={(title) => void rename(lane, title)}
|
onRenameEnd={(title) => void rename(lane, title)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{expanded(lane.id) && !folded && (
|
{expanded(lane.id) && !hiddenRow && (
|
||||||
<WorkspaceSessionList
|
<WorkspaceSessionList
|
||||||
lane={lane}
|
lane={lane}
|
||||||
liveRuns={liveRuns}
|
liveRuns={liveRuns}
|
||||||
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{firstUngrouped === -1 &&
|
||||||
|
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
|
||||||
{dropAt === sorted.length && (
|
{dropAt === sorted.length && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DropLine />
|
<DropLine />
|
||||||
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
|
|||||||
y={menu.y}
|
y={menu.y}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||||
|
groups={groups.groups}
|
||||||
|
groupId={groups.of[menu.lane.id] ?? null}
|
||||||
|
onGroup={(target) => {
|
||||||
|
const id = menu.lane.id;
|
||||||
|
setMenu(null);
|
||||||
|
if (target === "new") newGroup([id]);
|
||||||
|
else assignGroup(id, target);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{groupMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={groupMenu.x}
|
||||||
|
y={groupMenu.y}
|
||||||
|
onClose={() => setGroupMenu(null)}
|
||||||
|
testId={`workspace-group-menu-${groupMenu.group.id}`}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setRenamingGroup(groupMenu.group.id);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupRename")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={groups.groups[0]?.id === groupMenu.group.id}
|
||||||
|
onSelect={() => {
|
||||||
|
moveGroup(groupMenu.group.id, -1);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupMoveUp")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
|
||||||
|
onSelect={() => {
|
||||||
|
moveGroup(groupMenu.group.id, 1);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupMoveDown")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-add"
|
||||||
|
onSelect={() => {
|
||||||
|
const g = groupMenu.group;
|
||||||
|
setGroupMenu(null);
|
||||||
|
openAdd(undefined, g.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupAddWorkspace")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-fold-all"
|
||||||
|
onSelect={() => {
|
||||||
|
setGroupMenu(null);
|
||||||
|
setAllFolded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupFoldAll")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-unfold-all"
|
||||||
|
onSelect={() => {
|
||||||
|
setGroupMenu(null);
|
||||||
|
setAllFolded(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupUnfoldAll")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
testId="workspace-group-delete"
|
||||||
|
onSelect={() => {
|
||||||
|
const g = groupMenu.group;
|
||||||
|
setGroupMenu(null);
|
||||||
|
void removeGroup(g);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupDelete")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<DestructiveLaneModal
|
<DestructiveLaneModal
|
||||||
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
setAddPrefill(null);
|
setAddPrefill(null);
|
||||||
|
setAddToGroup(null);
|
||||||
}}
|
}}
|
||||||
onAdded={(lane) => {
|
onAdded={(lane) => {
|
||||||
|
if (addToGroup) assignGroup(lane.id, addToGroup);
|
||||||
void refreshLanes();
|
void refreshLanes();
|
||||||
select(lane.id);
|
select(lane.id);
|
||||||
}}
|
}}
|
||||||
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a group header's drag carries: the group's id. */
|
||||||
|
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
|
||||||
|
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||||
|
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
|
||||||
|
|
||||||
|
// needsYou outranks an error, an error a running agent; idle/active say nothing.
|
||||||
|
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
|
||||||
|
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
|
||||||
|
function worstOf(lanes: Lane[]): Lane | null {
|
||||||
|
let best: Lane | null = null;
|
||||||
|
let top = 0;
|
||||||
|
for (const l of lanes) {
|
||||||
|
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
|
||||||
|
if (u > top) [best, top] = [l, u];
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
/** A workspace's owner group: its user id, or "none". */
|
/** A workspace's owner group: its user id, or "none". */
|
||||||
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A section's header: a user group (foldable, menu, inline rename) or, with
|
||||||
|
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
|
||||||
|
*/
|
||||||
|
function GroupHeader({
|
||||||
|
group,
|
||||||
|
count,
|
||||||
|
worst,
|
||||||
|
holdsSelected,
|
||||||
|
folded,
|
||||||
|
renaming,
|
||||||
|
first,
|
||||||
|
onToggle,
|
||||||
|
onMenu,
|
||||||
|
onRenameEnd,
|
||||||
|
onGroupDrop,
|
||||||
|
}: {
|
||||||
|
group: WorkspaceGroup | null;
|
||||||
|
count: number;
|
||||||
|
/** The most urgent workspace inside, drawn as its dot while folded. */
|
||||||
|
worst: Lane | null;
|
||||||
|
/** The open workspace is in this group (marked while folded). */
|
||||||
|
holdsSelected: boolean;
|
||||||
|
folded: boolean;
|
||||||
|
renaming: boolean;
|
||||||
|
first: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onMenu: (x: number, y: number) => void;
|
||||||
|
/** The typed name, or null when the edit was cancelled. */
|
||||||
|
onRenameEnd: (name: string | null) => void;
|
||||||
|
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
|
||||||
|
onGroupDrop: (draggedId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const [draft, setDraft] = useState(group?.name ?? "");
|
||||||
|
const [dropOver, setDropOver] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (renaming) setDraft(group?.name ?? "");
|
||||||
|
}, [renaming, group?.name]);
|
||||||
|
const label = group ? group.name : t("tree.ungrouped");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-group-drop={group?.id ?? ""}
|
||||||
|
data-testid={`workspace-group-${group?.id ?? "none"}`}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (!group) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
// A group (never "Ungrouped") is dragged by its header to reorder.
|
||||||
|
draggable={!!group && !renaming}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (!group) return;
|
||||||
|
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!isGroupDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setDropOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDropOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
setDropOver(false);
|
||||||
|
if (!isGroupDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
|
||||||
|
if (id) onGroupDrop(id);
|
||||||
|
}}
|
||||||
|
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
|
||||||
|
>
|
||||||
|
{renaming && group ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
maxLength={60}
|
||||||
|
aria-label={t("tree.groupNameLabel")}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onBlur={() => onRenameEnd(draft)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onRenameEnd(draft);
|
||||||
|
else if (e.key === "Escape") onRenameEnd(null);
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={group ? !folded : undefined}
|
||||||
|
disabled={!group}
|
||||||
|
title={group ? t("tree.groupToggle", { name: label }) : undefined}
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
|
||||||
|
>
|
||||||
|
{group ? (
|
||||||
|
folded ? (
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
|
||||||
|
{folded && worst && (
|
||||||
|
<span
|
||||||
|
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
|
||||||
|
className="ml-1 flex shrink-0 items-center"
|
||||||
|
>
|
||||||
|
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
|
||||||
|
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{group && !renaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("tree.groupMenu")}
|
||||||
|
title={t("tree.groupMenu")}
|
||||||
|
onClick={(e) => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
onMenu(r.left, r.bottom);
|
||||||
|
}}
|
||||||
|
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||||
function DropLine() {
|
function DropLine() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
|
|||||||
import type { Agent } from "../../lib/types";
|
import type { Agent } from "../../lib/types";
|
||||||
import { formatModelName, fmtCost } from "../../lib/format";
|
import { formatModelName, fmtCost } from "../../lib/format";
|
||||||
|
|
||||||
function renderCard(element: JSX.Element) {
|
function renderCard(element: React.JSX.Element) {
|
||||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
|
|||||||
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
||||||
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
||||||
expect(titleFor("/settings")).toMatch(/^Settings · /);
|
expect(titleFor("/settings")).toMatch(/^Settings · /);
|
||||||
|
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
|
||||||
|
expect(titleFor("/files")).toMatch(/^Files · /);
|
||||||
|
expect(titleFor("/admin")).toMatch(/^Administration · /);
|
||||||
|
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("titles an unknown route as not found", () => {
|
||||||
|
expect(titleFor("/nope")).toMatch(/^Not found · /);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { api } from "../../lib/api";
|
|||||||
import { StatusBar } from "../StatusBar";
|
import { StatusBar } from "../StatusBar";
|
||||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||||
|
|
||||||
const at = (url: string, ui: JSX.Element) =>
|
const at = (url: string, ui: React.JSX.Element) =>
|
||||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||||
|
|
||||||
describe("StatusBar", () => {
|
describe("StatusBar", () => {
|
||||||
@@ -162,7 +162,7 @@ describe("StatusBar", () => {
|
|||||||
const conn = screen.getByRole("button", { name: /Live/ });
|
const conn = screen.getByRole("button", { name: /Live/ });
|
||||||
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
||||||
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
|
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
|
||||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
|
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
|
||||||
expect(cost).toHaveAttribute(
|
expect(cost).toHaveAttribute(
|
||||||
"title",
|
"title",
|
||||||
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||||
* selection
|
* selection
|
||||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||||
* search, the row menu's items by kind and tool, and the status dot.
|
* search, the row menu's items by kind and tool, the status dot, and the
|
||||||
|
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
|
||||||
|
* state dot, dragging a header to reorder, fold all).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -91,6 +93,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
|
||||||
|
vi.mock("../lanes/AddLaneModal", () => ({
|
||||||
|
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="stub-add-lane"
|
||||||
|
onClick={() => {
|
||||||
|
onAdded(lane(2, "/r/sub"));
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { WorkspaceTree } from "../WorkspaceTree";
|
import { WorkspaceTree } from "../WorkspaceTree";
|
||||||
import { workspaceDot } from "../lanes/WorkspaceRow";
|
import { workspaceDot } from "../lanes/WorkspaceRow";
|
||||||
@@ -576,6 +592,185 @@ describe("WorkspaceTree", () => {
|
|||||||
menu = screen.getByTestId("workspace-row-menu-3");
|
menu = screen.getByTestId("workspace-row-menu-3");
|
||||||
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-row-1");
|
||||||
|
// No group yet: no headers at all.
|
||||||
|
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||||
|
|
||||||
|
// A new group from the row menu holds that workspace and asks for a name.
|
||||||
|
await user.click(screen.getByTestId("workspace-menu-3"));
|
||||||
|
await user.click(screen.getByTestId("workspace-move-group-new"));
|
||||||
|
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||||
|
await user.clear(name);
|
||||||
|
await user.type(name, "Backend{Enter}");
|
||||||
|
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||||
|
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
|
||||||
|
const gid = stored.groups[0].id as string;
|
||||||
|
expect(stored.of).toEqual({ 3: gid });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
|
||||||
|
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
|
||||||
|
|
||||||
|
// Another workspace joins it from its menu.
|
||||||
|
await user.click(screen.getByTestId("workspace-menu-2"));
|
||||||
|
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Folding hides its rows; the ungrouped one stays.
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
|
||||||
|
expanded: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1"]);
|
||||||
|
|
||||||
|
// Deleting the group only ungroups: every workspace is still listed.
|
||||||
|
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-delete"));
|
||||||
|
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||||
|
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||||
|
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-row-1");
|
||||||
|
await user.click(screen.getByTestId("workspace-tree-new-group"));
|
||||||
|
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||||
|
await user.clear(name);
|
||||||
|
await user.type(name, "Later{Enter}");
|
||||||
|
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
|
||||||
|
const header = screen.getByTestId(`workspace-group-${gid}`);
|
||||||
|
expect(header).toHaveTextContent("Later0");
|
||||||
|
expect(
|
||||||
|
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(rowIds()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceTree groups", () => {
|
||||||
|
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
|
||||||
|
const seed = (extra: { folded?: boolean } = {}) =>
|
||||||
|
localStorage.setItem(
|
||||||
|
"agenthub.workspace.groups",
|
||||||
|
JSON.stringify({
|
||||||
|
groups: [
|
||||||
|
{ id: "ga", name: "Alpha", ...extra },
|
||||||
|
{ id: "gb", name: "Beta" },
|
||||||
|
],
|
||||||
|
of: { 1: "ga", 3: "gb" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||||
|
|
||||||
|
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
// The last of Alpha, Alt+↓: the first of Beta.
|
||||||
|
screen.getByTestId("workspace-open-1").focus();
|
||||||
|
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||||
|
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
|
||||||
|
// The last of Beta, Alt+↓: out into the ungrouped ones.
|
||||||
|
screen.getByTestId("workspace-open-3").focus();
|
||||||
|
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||||
|
expect(stored().of).toEqual({ 1: "gb" });
|
||||||
|
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a folded group's most urgent state and marks the open workspace", async () => {
|
||||||
|
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
|
||||||
|
seed({ folded: true });
|
||||||
|
renderTree("/workspace?lane=1");
|
||||||
|
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
|
||||||
|
const state = screen.getByTestId("workspace-group-state-ga");
|
||||||
|
expect(state).toHaveTextContent("running");
|
||||||
|
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
|
||||||
|
"text-accent"
|
||||||
|
);
|
||||||
|
// An open group shows its rows instead.
|
||||||
|
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
|
||||||
|
seed();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-gb");
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
const dataTransfer = {
|
||||||
|
get types() {
|
||||||
|
return [...data.keys()];
|
||||||
|
},
|
||||||
|
setData: (k: string, v: string) => data.set(k, v),
|
||||||
|
getData: (k: string) => data.get(k) ?? "",
|
||||||
|
dropEffect: "move",
|
||||||
|
effectAllowed: "move",
|
||||||
|
};
|
||||||
|
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||||
|
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||||
|
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||||
|
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
data.clear();
|
||||||
|
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||||
|
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
|
||||||
|
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
|
||||||
|
// A header drag never moves a workspace.
|
||||||
|
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a workspace added from a group's menu into that group", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-gb");
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-add"));
|
||||||
|
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||||
|
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
|
||||||
|
// The header's own Add belongs to no group: what it adds stays where it is.
|
||||||
|
act(() =>
|
||||||
|
localStorage.setItem(
|
||||||
|
"agenthub.workspace.groups",
|
||||||
|
JSON.stringify({ ...stored(), of: { 1: "ga" } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await user.click(screen.getByTestId("workspace-tree-add"));
|
||||||
|
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||||
|
expect(stored().of).toEqual({ 1: "ga" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds and unfolds every group from a header's menu", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-ga");
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-fold-all"));
|
||||||
|
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-unfold-all"));
|
||||||
|
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workspaceDot", () => {
|
describe("workspaceDot", () => {
|
||||||
|
|||||||
@@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
|
|||||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||||
import { throttle } from "../../lib/throttle";
|
import { throttle } from "../../lib/throttle";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
import type {
|
||||||
|
TranscriptMessage,
|
||||||
|
TranscriptInfo,
|
||||||
|
TranscriptResult,
|
||||||
|
WSMessage,
|
||||||
|
} from "../../lib/types";
|
||||||
|
|
||||||
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
||||||
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
||||||
@@ -175,6 +180,8 @@ function ConversationViewPane({
|
|||||||
initialTranscriptId ?? null
|
initialTranscriptId ?? null
|
||||||
);
|
);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
|
||||||
|
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||||
@@ -283,6 +290,7 @@ function ConversationViewPane({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setNotStarted(false);
|
setNotStarted(false);
|
||||||
setMessages(result.messages);
|
setMessages(result.messages);
|
||||||
|
setLeadEdits(result.turn_edits);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setHasMore(result.has_more);
|
setHasMore(result.has_more);
|
||||||
lastLineRef.current = result.last_line;
|
lastLineRef.current = result.last_line;
|
||||||
@@ -343,6 +351,7 @@ function ConversationViewPane({
|
|||||||
// Seed the view in a single render so the user sees the whole
|
// Seed the view in a single render so the user sees the whole
|
||||||
// catch-up batch instead of a blank panel followed by a partial one.
|
// catch-up batch instead of a blank panel followed by a partial one.
|
||||||
setMessages(result.messages);
|
setMessages(result.messages);
|
||||||
|
setLeadEdits(result.turn_edits);
|
||||||
firstLineRef.current = result.first_line;
|
firstLineRef.current = result.first_line;
|
||||||
setHasMore(result.has_more);
|
setHasMore(result.has_more);
|
||||||
} else {
|
} else {
|
||||||
@@ -487,6 +496,7 @@ function ConversationViewPane({
|
|||||||
firstLineRef.current = result.first_line;
|
firstLineRef.current = result.first_line;
|
||||||
|
|
||||||
setMessages((prev) => [...result.messages, ...prev]);
|
setMessages((prev) => [...result.messages, ...prev]);
|
||||||
|
setLeadEdits(result.turn_edits);
|
||||||
setHasMore(result.has_more);
|
setHasMore(result.has_more);
|
||||||
|
|
||||||
// Preserve scroll position (don't jump to top)
|
// Preserve scroll position (don't jump to top)
|
||||||
@@ -686,6 +696,7 @@ function ConversationViewPane({
|
|||||||
stopped={stopped}
|
stopped={stopped}
|
||||||
agentLinks={agentLinks}
|
agentLinks={agentLinks}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
leadEdits={leadEdits}
|
||||||
sessionId={
|
sessionId={
|
||||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ interface MessageListProps {
|
|||||||
/** A subagent's conversation inside its step: tight spacing, and the task
|
/** A subagent's conversation inside its step: tight spacing, and the task
|
||||||
* the orchestrator gave it reads as the prompt it is. */
|
* the orchestrator gave it reads as the prompt it is. */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** The files the first, cut-off turn changed before `messages` begins (the
|
||||||
|
* transcript page's `turn_edits`): counted in that turn's card. */
|
||||||
|
leadEdits?: TranscriptContent["edits"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a map from tool_use id → tool_result for matching */
|
/** Build a map from tool_use id → tool_result for matching */
|
||||||
@@ -491,6 +494,10 @@ type Item =
|
|||||||
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
||||||
|
|
||||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||||
|
// A subagent's result carries the files IT changed (`edits`): they count too.
|
||||||
|
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||||
|
// The stand-in for the part of a turn the page cut off: its `edits` count.
|
||||||
|
const LEAD = "__lead_edits";
|
||||||
|
|
||||||
/** A line that is only Claude Code's local-command plumbing — the caveat it
|
/** A line that is only Claude Code's local-command plumbing — the caveat it
|
||||||
* writes first, the command, what the command printed — or null for
|
* writes first, the command, what the command printed — or null for
|
||||||
@@ -515,11 +522,18 @@ export function localCommand(
|
|||||||
return { name, output, error };
|
return { name, output, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
export function chatItems(
|
||||||
|
messages: TranscriptMessage[],
|
||||||
|
keys: string[],
|
||||||
|
leadEdits?: TranscriptContent["edits"]
|
||||||
|
): Item[] {
|
||||||
const items: Item[] = [];
|
const items: Item[] = [];
|
||||||
// The file-changing calls of the turn so far, closed into one card when the
|
// The file-changing calls of the turn so far, closed into one card when the
|
||||||
// human speaks again (or the list ends).
|
// human speaks again (or the list ends) — starting with what the page's
|
||||||
let edits: TranscriptContent[] = [];
|
// cut-off first turn changed before it.
|
||||||
|
let edits: TranscriptContent[] = leadEdits?.length
|
||||||
|
? [{ type: "tool_use", name: LEAD, edits: leadEdits }]
|
||||||
|
: [];
|
||||||
// The turn's prompt time, its newest assistant line and its output so far,
|
// The turn's prompt time, its newest assistant line and its output so far,
|
||||||
// for the "Done in …" line under a turn that ended.
|
// for the "Done in …" line under a turn that ended.
|
||||||
let start: string | null = null;
|
let start: string | null = null;
|
||||||
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
|||||||
if (msg.usage) output.add(msg.usage);
|
if (msg.usage) output.add(msg.usage);
|
||||||
msg.content.forEach((block, b) => {
|
msg.content.forEach((block, b) => {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
if (
|
||||||
|
block.type === "tool_use" &&
|
||||||
|
(EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? ""))
|
||||||
|
)
|
||||||
|
edits.push(block);
|
||||||
// A question to the user is a turn of the conversation, not hidden work.
|
// A question to the user is a turn of the conversation, not hidden work.
|
||||||
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||||
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
||||||
@@ -672,19 +690,38 @@ function EditedFilesCard({
|
|||||||
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||||
// Written whole in this turn (a Write): a component made here is an artifact.
|
// Written whole in this turn (a Write): a component made here is an artifact.
|
||||||
const created = new Set<string>();
|
const created = new Set<string>();
|
||||||
|
// Changed only by a subagent: listed, never an artifact card — the chat
|
||||||
|
// rebuilds an artifact from this transcript's Write/Edit inputs, which a
|
||||||
|
// subagent's are not.
|
||||||
|
const byAgent = new Set<string>();
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
|
const r0 = b.id ? toolResultMap.get(b.id) : undefined;
|
||||||
|
if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) {
|
||||||
|
for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) {
|
||||||
|
if (!files.has(e.path)) byAgent.add(e.path);
|
||||||
|
const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false };
|
||||||
|
f.added += e.added;
|
||||||
|
f.removed += e.removed;
|
||||||
|
f.counted ||= e.counted;
|
||||||
|
files.set(e.path, f);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||||
const p = input.file_path ?? input.notebook_path;
|
const p = input.file_path ?? input.notebook_path;
|
||||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||||
if (b.name === "Write") created.add(p);
|
if (b.name === "Write") created.add(p);
|
||||||
|
byAgent.delete(p);
|
||||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||||
f.added += r.diff?.added ?? 0;
|
f.added += r.diff?.added ?? 0;
|
||||||
f.removed += r.diff?.removed ?? 0;
|
f.removed += r.diff?.removed ?? 0;
|
||||||
f.counted ||= !!r.diff;
|
f.counted ||= !!r.diff;
|
||||||
files.set(p, f);
|
files.set(p, f);
|
||||||
}
|
}
|
||||||
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
|
const artifacts = [...files.keys()].filter(
|
||||||
|
(p) => !byAgent.has(p) && isArtifact(p, created.has(p))
|
||||||
|
);
|
||||||
const cards = artifacts.length > 0 && (
|
const cards = artifacts.length > 0 && (
|
||||||
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
||||||
{artifacts.map((p) => (
|
{artifacts.map((p) => (
|
||||||
@@ -773,6 +810,7 @@ function MessageListView({
|
|||||||
sessionId,
|
sessionId,
|
||||||
agentLinks,
|
agentLinks,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
leadEdits,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||||
@@ -798,9 +836,10 @@ function MessageListView({
|
|||||||
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
|
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
|
||||||
)
|
)
|
||||||
: messages,
|
: messages,
|
||||||
keys
|
keys,
|
||||||
|
leadEdits
|
||||||
),
|
),
|
||||||
[messages, keys, compact]
|
[messages, keys, compact, leadEdits]
|
||||||
);
|
);
|
||||||
const agents = useMemo(
|
const agents = useMemo(
|
||||||
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||||
* removed summed over the turn, a failed call left out, a row click handed
|
* removed summed over the turn, a failed call left out, a row click handed
|
||||||
* to the caller, and a row (or the header, every file) dragged as `@path`
|
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||||
* attachments for a prompt box.
|
* attachments for a prompt box. Files a subagent changed (its Agent
|
||||||
|
* result's `edits`, read from its own transcript) are counted in the same
|
||||||
|
* card, never as an artifact; so are the files a turn changed before the
|
||||||
|
* loaded page began (`leadEdits`, the page's `turn_edits`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
@@ -127,4 +130,71 @@ describe("the Edited N files card", () => {
|
|||||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts the files a subagent changed, beside the turn's own edits", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[
|
||||||
|
user("run the task"),
|
||||||
|
call("1", "Edit", "/w/a.go"),
|
||||||
|
result("1", { added: 1, removed: 0 }),
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
|
||||||
|
} as TranscriptMessage,
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sender: "tool",
|
||||||
|
timestamp: at,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id: "2",
|
||||||
|
output: "done",
|
||||||
|
agent: { id: "x1" },
|
||||||
|
edits: [
|
||||||
|
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
|
||||||
|
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
|
||||||
|
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as TranscriptMessage,
|
||||||
|
user("thanks"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const card = screen.getByTestId("edited-files");
|
||||||
|
expect(card.textContent).toContain("Edited 3 files");
|
||||||
|
expect(within(card).getByText("b_test.go")).toBeTruthy();
|
||||||
|
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
|
||||||
|
// A page only a subagent wrote is listed, not shown as an artifact card.
|
||||||
|
expect(within(card).getByText("report.html")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("artifact-card")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
|
||||||
|
messages={[
|
||||||
|
call("1", "Write", "/w/handler_test.go"),
|
||||||
|
result("1", { added: 113, removed: 0 }),
|
||||||
|
user("what changed?"),
|
||||||
|
call("2", "Edit", "/w/x.go"),
|
||||||
|
result("2", { added: 1, removed: 0 }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const [first, second] = screen.getAllByTestId("edited-files");
|
||||||
|
expect(first!.textContent).toContain("Edited 2 files");
|
||||||
|
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
|
||||||
|
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
|
||||||
|
// The next turn's card is its own.
|
||||||
|
expect(second!.textContent).toContain("Edited 1 file");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
type FsTrashItem,
|
type FsTrashItem,
|
||||||
type FsHome,
|
type FsHome,
|
||||||
type FsPlace,
|
type FsPlace,
|
||||||
|
type GitSource,
|
||||||
isHomePlace,
|
isHomePlace,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
@@ -283,7 +284,7 @@ export function FileBrowser({
|
|||||||
/** A home to open on (`me`, or another user's for an admin). */
|
/** A home to open on (`me`, or another user's for an admin). */
|
||||||
initialHome?: string | null;
|
initialHome?: string | null;
|
||||||
session?: string | null;
|
session?: string | null;
|
||||||
source?: number | null;
|
source?: GitSource | null;
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
@@ -1882,7 +1883,7 @@ export function FileExplorer({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session?: string | null;
|
session?: string | null;
|
||||||
source?: number | null;
|
source?: GitSource | null;
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
UnfoldVertical,
|
UnfoldVertical,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
import { pasteToLane } from "../../lib/panePaste";
|
import { pasteToLane } from "../../lib/panePaste";
|
||||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||||
@@ -827,7 +827,7 @@ export function LaneChangesPanel({
|
|||||||
session?: string;
|
session?: string;
|
||||||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||||
* wins over `session`, and is keyed on like it. */
|
* wins over `session`, and is keyed on like it. */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||||
onNewLane?: (branch?: string) => void;
|
onNewLane?: (branch?: string) => void;
|
||||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
applyChoices,
|
applyChoices,
|
||||||
parseConflicts,
|
parseConflicts,
|
||||||
@@ -47,7 +47,7 @@ export function LaneConflictView({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
path: string;
|
path: string;
|
||||||
/** The file left the conflict (resolved, or a whole side taken). */
|
/** The file left the conflict (resolved, or a whole side taken). */
|
||||||
onResolved: () => void;
|
onResolved: () => void;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
|
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
@@ -98,7 +98,7 @@ export function LaneGitBar({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
git: LaneGitFacts;
|
git: LaneGitFacts;
|
||||||
/** Rendered at the start of the action row: the header's branch line, so
|
/** Rendered at the start of the action row: the header's branch line, so
|
||||||
* the facts and the buttons that act on them read as one row. */
|
* the facts and the buttons that act on them read as one row. */
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GitBranch } from "lucide-react";
|
import { GitBranch } from "lucide-react";
|
||||||
import { LaneGitBar } from "./LaneGitBar";
|
import { LaneGitBar } from "./LaneGitBar";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import { keepIfSame } from "../../lib/keepIfSame";
|
import { keepIfSame } from "../../lib/keepIfSame";
|
||||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ export function useLaneGitFacts(
|
|||||||
laneId: number,
|
laneId: number,
|
||||||
/** Read that session's own worktree instead (`?session=`). */
|
/** Read that session's own worktree instead (`?session=`). */
|
||||||
session?: string,
|
session?: string,
|
||||||
/** Read that extra folder's repo instead (`?source=`). */
|
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
|
||||||
source?: number
|
source?: GitSource
|
||||||
): [LaneGitFacts | null, () => void] {
|
): [LaneGitFacts | null, () => void] {
|
||||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||||
@@ -329,7 +329,7 @@ export function LaneGitRailCounts({
|
|||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session?: string;
|
session?: string;
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
/** Opens the Changes panel from the file list's "… more files". */
|
/** Opens the Changes panel from the file list's "… more files". */
|
||||||
onShowAll?: () => void;
|
onShowAll?: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -349,7 +349,7 @@ export function LaneGitHeader({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
git: LaneGitFacts | null;
|
git: LaneGitFacts | null;
|
||||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||||
* existing call: Rename (the row's inline field), New workspace here (the
|
* existing call: Rename (the row's inline field), New workspace here (the
|
||||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||||
* managed lane. The server's guards are the real ones; the menu only hides
|
* managed lane — plus Move to group (the sidebar's own groups,
|
||||||
|
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
|
||||||
|
* server's guards are the real ones; the menu only hides
|
||||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||||
* Escape / outside-click close) is `ContextMenu`.
|
* Escape / outside-click close) is `ContextMenu`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -11,7 +13,9 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||||
|
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
|
||||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||||
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
|
|||||||
y,
|
y,
|
||||||
onClose,
|
onClose,
|
||||||
onAction,
|
onAction,
|
||||||
|
groups = [],
|
||||||
|
groupId = null,
|
||||||
|
onGroup,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Items the tree itself carries out (rename field, modals). */
|
/** Items the tree itself carries out (rename field, modals). */
|
||||||
onAction: (action: WorkspaceMenuAction) => void;
|
onAction: (action: WorkspaceMenuAction) => void;
|
||||||
|
/** The sidebar's groups and the one this workspace is in. */
|
||||||
|
groups?: WorkspaceGroup[];
|
||||||
|
groupId?: string | null;
|
||||||
|
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
|
||||||
|
onGroup?: (target: string | null | "new") => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
|
|||||||
>
|
>
|
||||||
{t("menu.copyPath")}
|
{t("menu.copyPath")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{onGroup && (
|
||||||
|
<>
|
||||||
|
<MenuSeparator />
|
||||||
|
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
|
{t("menu.moveToGroup")}
|
||||||
|
</div>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<MenuItem
|
||||||
|
key={g.id}
|
||||||
|
testId={`workspace-move-group-${g.id}`}
|
||||||
|
disabled={g.id === groupId}
|
||||||
|
onSelect={() => onGroup(g.id)}
|
||||||
|
>
|
||||||
|
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
|
||||||
|
<span className="truncate">{g.name}</span>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
{t("menu.newGroupWith")}
|
||||||
|
</MenuItem>
|
||||||
|
{groupId && (
|
||||||
|
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
{t("menu.removeFromGroup")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{lane.kind === "managed" && (
|
{lane.kind === "managed" && (
|
||||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
|
|||||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||||
tool("gemini", "Gemini"),
|
tool("gemini", "Gemini"),
|
||||||
tool("aider", "Aider", { installed: false, path: null }),
|
tool("aider", "Aider", { installed: false, path: null }),
|
||||||
tool("grok", "Grok", { enabled: false }),
|
tool("goose", "Goose", { enabled: false }),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||||
name: "Tick every file in server/lib",
|
name: "Tick every file in server/lib",
|
||||||
});
|
});
|
||||||
expect(box).toBeChecked();
|
// The default ticks land in an effect after the list renders.
|
||||||
|
await waitFor(() => expect(box).toBeChecked());
|
||||||
await user.click(box);
|
await user.click(box);
|
||||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||||
await user.click(folder);
|
await user.click(folder);
|
||||||
|
|||||||
@@ -765,7 +765,7 @@ export function Divider({
|
|||||||
onRatio,
|
onRatio,
|
||||||
label,
|
label,
|
||||||
}: {
|
}: {
|
||||||
gridRef: React.RefObject<HTMLDivElement>;
|
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||||
dir: SplitDir;
|
dir: SplitDir;
|
||||||
at: number;
|
at: number;
|
||||||
box: Rect;
|
box: Rect;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||||
* as an `@path` chip — so is a local file a browser on the dashboard's
|
* as an `@path` chip — so is a local file a browser on the dashboard's
|
||||||
* own machine drops or pastes with its `file://` path, never uploaded —
|
* own machine drops or pastes with its `file://` path, or pastes from a
|
||||||
|
* file manager's Copy its OS clipboard holds, never uploaded —
|
||||||
* a `bare` one takes a drop anywhere on its box;
|
* a `bare` one takes a drop anywhere on its box;
|
||||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||||
* the textarea (`lib/promptTokens.ts`);
|
* the textarea (`lib/promptTokens.ts`);
|
||||||
@@ -78,6 +79,8 @@ import {
|
|||||||
localAttachment,
|
localAttachment,
|
||||||
localCandidates,
|
localCandidates,
|
||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
|
fileManagerPaths,
|
||||||
|
isClipboardBitmap,
|
||||||
SKILL_MIME,
|
SKILL_MIME,
|
||||||
rememberUploads,
|
rememberUploads,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
@@ -463,21 +466,26 @@ export function PromptEditor({
|
|||||||
};
|
};
|
||||||
/** Dropped or pasted files: by their own paths when the browser is on the
|
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||||
* dashboard's machine and the server finds the same files there (a file
|
* dashboard's machine and the server finds the same files there (a file
|
||||||
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
* manager hands over `file://` paths beside the bytes) or its file manager
|
||||||
|
* holds them (a paste: its clipboard; a drop: its selection — no upload,
|
||||||
|
* no size limit), else uploaded. */
|
||||||
const attachDropped = (
|
const attachDropped = (
|
||||||
dt: DataTransfer | null | undefined,
|
dt: DataTransfer | null | undefined,
|
||||||
list: FileList | null | undefined
|
list: FileList | File[] | null | undefined,
|
||||||
|
from: "paste" | "drop"
|
||||||
) => {
|
) => {
|
||||||
const files = Array.from(list ?? []);
|
const files = Array.from(list ?? []);
|
||||||
if (files.length === 0) return false;
|
if (files.length === 0) return false;
|
||||||
const paths = localCandidates(dt, files);
|
if (!isLocalBrowser()) return attachImages(files);
|
||||||
if (!paths) return attachImages(files);
|
const candidates = localCandidates(dt, files);
|
||||||
setUpload("uploading");
|
setUpload("uploading");
|
||||||
void sameFilesHere(paths, files).then((same) => {
|
void (async () => {
|
||||||
if (!same) return void attachImages(files);
|
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
|
||||||
|
paths ??= await fileManagerPaths(files, from);
|
||||||
|
if (!paths) return void attachImages(files);
|
||||||
setUpload(null);
|
setUpload(null);
|
||||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||||
});
|
})();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||||
@@ -518,7 +526,7 @@ export function PromptEditor({
|
|||||||
} else if (skill) {
|
} else if (skill) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) e.preventDefault();
|
||||||
else {
|
else {
|
||||||
// Only text (a file manager's or an editor's `file://` list, a
|
// Only text (a file manager's or an editor's `file://` list, a
|
||||||
// selection, a link): what the terminal beneath would paste.
|
// selection, a link): what the terminal beneath would paste.
|
||||||
@@ -683,17 +691,21 @@ export function PromptEditor({
|
|||||||
onBlur={() => setTrigger(null)}
|
onBlur={() => setTrigger(null)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
const pasted = Array.from(e.clipboardData?.files ?? []);
|
||||||
else if (pastedLocalFiles(e.clipboardData)) e.preventDefault();
|
const text = e.clipboardData?.getData?.("text/plain") ?? "";
|
||||||
else {
|
// Cells from a spreadsheet come with a picture of them: the text
|
||||||
|
// is what was copied (isClipboardBitmap).
|
||||||
|
const textWithPicture =
|
||||||
|
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
|
||||||
|
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
|
||||||
|
e.preventDefault();
|
||||||
|
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
|
||||||
|
else if (text.length > PASTE_AS_FILE_CHARS) {
|
||||||
// A very long paste goes in as a text file, like Claude Desktop:
|
// A very long paste goes in as a text file, like Claude Desktop:
|
||||||
// the prompt then carries its path, never the text (#68: a CLI's
|
// the prompt then carries its path, never the text (#68: a CLI's
|
||||||
// argument and a tmux message both have a size limit).
|
// argument and a tmux message both have a size limit).
|
||||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
e.preventDefault();
|
||||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||||
e.preventDefault();
|
|
||||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||||
* reads them when the prompt is sent — an image as its bare path, which claude
|
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||||
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
|
* shows as `[Image #n]` like a native drop — except a PASTED image in a
|
||||||
|
* `claude` pane of a local-mode dashboard opened on its own machine, which is
|
||||||
|
* sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the
|
||||||
|
* desktop clipboard itself, uploading nothing — and a file manager's files
|
||||||
|
* pasted or dropped on that machine go in by the paths it holds them under
|
||||||
|
* (its clipboard / its selection: `POST /api/run/clipboard-files`,
|
||||||
|
* `selected-files`), with no upload and no size limit
|
||||||
|
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
|
||||||
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||||
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||||
@@ -41,7 +48,9 @@
|
|||||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||||
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
||||||
* here takes the lead back (`resize` with `drive: true`).
|
* here takes the lead back (`resize` with `drive: true`). Cells are drawn by
|
||||||
|
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
|
||||||
|
* the word-move keys xterm 5 sent (`altArrowSeq`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +65,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
import type { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||||
import type { SearchAddon } from "@xterm/addon-search";
|
import type { SearchAddon } from "@xterm/addon-search";
|
||||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||||
@@ -69,16 +78,20 @@ import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "..
|
|||||||
import {
|
import {
|
||||||
claudePastes,
|
claudePastes,
|
||||||
copiedFilePaths,
|
copiedFilePaths,
|
||||||
|
isClipboardBitmap,
|
||||||
droppedFilePaths,
|
droppedFilePaths,
|
||||||
|
fileManagerPaths,
|
||||||
isLocalBrowser,
|
isLocalBrowser,
|
||||||
localCandidates,
|
localCandidates,
|
||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
|
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||||
|
|
||||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
|
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
||||||
|
|
||||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||||
@@ -112,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
|
|||||||
* Workspace chunk. The result is cached, so every later pane (and a test that
|
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||||
* awaits `loadXterm()` first) sets up synchronously. */
|
* awaits `loadXterm()` first) sets up synchronously. */
|
||||||
const importXterm = async () => {
|
const importXterm = async () => {
|
||||||
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
|
const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
|
||||||
import("@xterm/xterm"),
|
import("@xterm/xterm"),
|
||||||
import("@xterm/addon-fit"),
|
import("@xterm/addon-fit"),
|
||||||
import("@xterm/addon-canvas"),
|
import("@xterm/addon-webgl"),
|
||||||
import("@xterm/addon-clipboard"),
|
import("@xterm/addon-clipboard"),
|
||||||
import("@xterm/addon-search"),
|
import("@xterm/addon-search"),
|
||||||
import("@xterm/addon-web-links"),
|
import("@xterm/addon-web-links"),
|
||||||
@@ -123,7 +136,7 @@ const importXterm = async () => {
|
|||||||
return {
|
return {
|
||||||
Terminal: xterm.Terminal,
|
Terminal: xterm.Terminal,
|
||||||
FitAddon: fit.FitAddon,
|
FitAddon: fit.FitAddon,
|
||||||
CanvasAddon: canvas.CanvasAddon,
|
WebglAddon: webgl.WebglAddon,
|
||||||
SearchAddon: search.SearchAddon,
|
SearchAddon: search.SearchAddon,
|
||||||
WebLinksAddon: links.WebLinksAddon,
|
WebLinksAddon: links.WebLinksAddon,
|
||||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||||
@@ -147,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
|
|||||||
return xtermLoading;
|
return xtermLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
|
||||||
|
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
|
||||||
|
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
|
||||||
|
* readline's backward-word / forward-word and whose up/down stay Alt's. */
|
||||||
|
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
|
||||||
|
switch (key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
return mac ? "\x1bb" : "\x1b[1;5D";
|
||||||
|
case "ArrowRight":
|
||||||
|
return mac ? "\x1bf" : "\x1b[1;5C";
|
||||||
|
case "ArrowUp":
|
||||||
|
return mac ? null : "\x1b[1;5A";
|
||||||
|
case "ArrowDown":
|
||||||
|
return mac ? null : "\x1b[1;5B";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
||||||
const RETRY_MIN_MS = 1500;
|
const RETRY_MIN_MS = 1500;
|
||||||
const RETRY_MAX_MS = 30_000;
|
const RETRY_MAX_MS = 30_000;
|
||||||
@@ -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).
|
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||||
const ctrlVRef = useRef(false);
|
const ctrlVRef = useRef(false);
|
||||||
|
const { local: localMode } = usePermissions();
|
||||||
|
// Ask early whether `^V` can reach this machine's clipboard, so the first
|
||||||
|
// pasted screenshot already takes the right way.
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks();
|
||||||
|
}, [tool, localMode]);
|
||||||
|
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
if (hasText(e)) {
|
if (hasText(e)) {
|
||||||
@@ -256,7 +294,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
void attachFiles(files, localCandidates(e.dataTransfer, files));
|
void attachFiles(files, localCandidates(e.dataTransfer, files), "drop");
|
||||||
};
|
};
|
||||||
|
|
||||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||||
@@ -277,9 +315,27 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||||
|
// Cells from a spreadsheet, a page's selection: text WITH a picture of
|
||||||
|
// it. A terminal pastes the text there, so does this.
|
||||||
|
if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
// On the dashboard's own machine the browser's clipboard IS the pane's:
|
||||||
|
// `claude` reads a pasted image itself on ^V, as in a native terminal —
|
||||||
|
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
|
||||||
|
const nativeImage =
|
||||||
|
tool === "claude" &&
|
||||||
|
localMode &&
|
||||||
|
isLocalBrowser() &&
|
||||||
|
readNativePaste() &&
|
||||||
|
nativePasteWorks() &&
|
||||||
|
files.length > 0 &&
|
||||||
|
files.every(isClipboardBitmap);
|
||||||
|
if (nativeImage) {
|
||||||
|
termRef.current?.input(nativePasteKey(), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste");
|
||||||
else termRef.current?.input("\x16", true);
|
else termRef.current?.input("\x16", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -299,11 +355,15 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||||
* browser on the dashboard's machine is handed beside the bytes) once the
|
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||||
* server finds the same files there — nothing uploaded —, else the paths
|
* server finds the same files there — nothing uploaded —, else the paths
|
||||||
* the uploads were saved at. A refused file does not cost the others. */
|
* that machine's file manager holds them under (a paste: its clipboard; a
|
||||||
const attachFiles = async (files: File[], local: string[] | null = null) => {
|
* drop: its selection, what the drag carries — no upload, no size limit,
|
||||||
|
* as a native terminal), else the paths the uploads were saved at. A
|
||||||
|
* refused file does not cost the others. */
|
||||||
|
const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
setDrop("uploading");
|
setDrop("uploading");
|
||||||
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
||||||
|
paths ??= await fileManagerPaths(files, from);
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
if (!paths) {
|
if (!paths) {
|
||||||
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
||||||
@@ -321,7 +381,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||||
if (!errors.length) setDrop(null);
|
if (!errors.length) setDrop(null);
|
||||||
else if (!tooBig) flash("failed");
|
else if (!tooBig) flash("failed");
|
||||||
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
|
// A terminal has no + menu: on this machine, a paste goes by path.
|
||||||
|
else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -329,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
const attach = ({
|
const attach = ({
|
||||||
Terminal,
|
Terminal,
|
||||||
FitAddon,
|
FitAddon,
|
||||||
CanvasAddon,
|
WebglAddon,
|
||||||
SearchAddon,
|
SearchAddon,
|
||||||
WebLinksAddon,
|
WebLinksAddon,
|
||||||
ClipboardWithProvider,
|
ClipboardWithProvider,
|
||||||
@@ -339,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||||
// substitutes those specific glyphs from a different face with a
|
// substitutes those specific glyphs from a different face with a
|
||||||
// mismatched advance width, corrupting the cell it's drawn into even
|
// mismatched advance width, corrupting the cell it's drawn into even
|
||||||
// under the canvas renderer (which only stops a glyph from shoving its
|
// under the WebGL renderer (which only stops a glyph from shoving its
|
||||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||||
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||||
@@ -385,17 +446,30 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// face whose advance width differs, so glyphs shove their neighbours along
|
// face whose advance width differs, so glyphs shove their neighbours along
|
||||||
// and the row's tail spills onto the row below. The buffer is correct
|
// and the row's tail spills onto the row below. The buffer is correct
|
||||||
// throughout (copying the line yields clean text); only the painting drifts,
|
// throughout (copying the line yields clean text); only the painting drifts,
|
||||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
|
||||||
// context keeps the DOM renderer rather than losing the terminal.
|
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
|
||||||
let canvas: CanvasAddon | null = null;
|
// keeps the DOM renderer rather than losing the terminal, and so does a
|
||||||
|
// pane whose context the browser takes back (GPU reset, or past the
|
||||||
|
// browser's cap of live contexts with many panes open) — disposing the
|
||||||
|
// addon hands the drawing back to the DOM renderer.
|
||||||
|
let gpu: WebglAddon | null = null;
|
||||||
try {
|
try {
|
||||||
canvas = new CanvasAddon();
|
const addon = new WebglAddon();
|
||||||
term.loadAddon(canvas);
|
term.loadAddon(addon);
|
||||||
|
gpu = addon;
|
||||||
|
addon.onContextLoss(() => {
|
||||||
|
addon.dispose();
|
||||||
|
if (gpu === addon) gpu = null;
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
canvas = null;
|
gpu = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fit.fit();
|
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||||
|
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||||
|
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||||
|
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||||
|
if (hasBox()) fit.fit();
|
||||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||||
// page here, and having to click it first meant the first thing typed went
|
// page here, and having to click it first meant the first thing typed went
|
||||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||||
@@ -482,6 +556,17 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
term.input("\x1f", true);
|
term.input("\x1f", true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
|
||||||
|
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
|
||||||
|
// ESC b / ESC f for left/right on macOS — the same keys tmux,
|
||||||
|
// bash and claude's prompt have always been handed from this pane.
|
||||||
|
const altArrow =
|
||||||
|
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
|
||||||
|
if (altArrow) {
|
||||||
|
ev.preventDefault();
|
||||||
|
term.input(altArrow, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
@@ -524,7 +609,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
*/
|
*/
|
||||||
const sendSizeNow = (drive = false) => {
|
const sendSizeNow = (drive = false) => {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
if (term.cols < 2 || term.rows < 2) return;
|
if (!hasBox() || term.cols < 2 || term.rows < 2) return;
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
||||||
};
|
};
|
||||||
// This browser is the one in use now: back to its own size, and the
|
// This browser is the one in use now: back to its own size, and the
|
||||||
@@ -561,7 +646,10 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// scrollback as torn fragments along the left margin, visible as soon as
|
// scrollback as torn fragments along the left margin, visible as soon as
|
||||||
// you scroll back (issue #33). Happens on every attach: page load,
|
// you scroll back (issue #33). Happens on every attach: page load,
|
||||||
// lane switch, and each reconnect.
|
// lane switch, and each reconnect.
|
||||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
// A hidden terminal sends none: the server attaches it at the window's
|
||||||
|
// own size, so a pane on a background tab is never resized by it.
|
||||||
|
const size =
|
||||||
|
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||||
ws = sock;
|
ws = sock;
|
||||||
wsRef.current = sock;
|
wsRef.current = sock;
|
||||||
@@ -656,6 +744,9 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||||
if (follow) return scaleToFit(); // the driver's grid, not ours
|
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||||
fit.fit(); // a real change fires onResize, which reports it
|
fit.fit(); // a real change fires onResize, which reports it
|
||||||
|
// A tab shown for the first time may fit to the very size xterm already
|
||||||
|
// had (no onResize), yet tmux has never heard it: report it anyway.
|
||||||
|
sendSize();
|
||||||
};
|
};
|
||||||
const resizeObserver = new ResizeObserver(refit);
|
const resizeObserver = new ResizeObserver(refit);
|
||||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||||
@@ -694,7 +785,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
resizeDisposable.dispose();
|
resizeDisposable.dispose();
|
||||||
dataDisposable.dispose();
|
dataDisposable.dispose();
|
||||||
ws?.close();
|
ws?.close();
|
||||||
canvas?.dispose();
|
gpu?.dispose();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -939,7 +1030,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
<div
|
<div
|
||||||
data-testid="terminal-drop"
|
data-testid="terminal-drop"
|
||||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||||
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
|
drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
|
||||||
? "border-red-400 text-red-300"
|
? "border-red-400 text-red-300"
|
||||||
: "border-sky-400 text-sky-200"
|
: "border-sky-400 text-sky-200"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
files: r({ items: [] }),
|
files: r({ items: [] }),
|
||||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||||
localPaths: r({ sizes: [1] }),
|
localPaths: r({ sizes: [1] }),
|
||||||
|
clipboardFiles: r({ files: [] }),
|
||||||
|
selectedFiles: r({ files: [] }),
|
||||||
},
|
},
|
||||||
ccConfig: {
|
ccConfig: {
|
||||||
commands: r({
|
commands: r({
|
||||||
@@ -377,6 +379,35 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chrome on Windows pastes a file manager's copy as bytes only: the path
|
||||||
|
// comes off the OS clipboard, so a file over the upload limit attaches too.
|
||||||
|
it("attaches a pasted file manager copy by the path the OS clipboard holds", async () => {
|
||||||
|
vi.mocked(api.run.clipboardFiles).mockResolvedValueOnce({
|
||||||
|
files: [{ path: "C:\\Users\\u\\big.iso", size: 3_000_000_000, dir: false }],
|
||||||
|
});
|
||||||
|
vi.mocked(api.run.dropFile).mockClear();
|
||||||
|
const onImagesChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<PromptEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash={false}
|
||||||
|
fileCwd="/home/u"
|
||||||
|
images={[]}
|
||||||
|
onImagesChange={onImagesChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const iso = new File(["x"], "big.iso");
|
||||||
|
Object.defineProperty(iso, "size", { value: 3_000_000_000 });
|
||||||
|
fireEvent.paste(box(), { clipboardData: { files: [iso], getData: () => "" } });
|
||||||
|
await vi.waitFor(() => expect(onImagesChange).toHaveBeenCalled());
|
||||||
|
const update = onImagesChange.mock.calls[0]![0] as (p: unknown[]) => { path: string }[];
|
||||||
|
expect(update([]).map((a) => a.path)).toEqual(["@C:/Users/u/big.iso"]);
|
||||||
|
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
||||||
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
||||||
const onImagesChange = vi.fn();
|
const onImagesChange = vi.fn();
|
||||||
|
|||||||
@@ -6,13 +6,24 @@
|
|||||||
* included) as outgoing binary frames, and
|
* included) as outgoing binary frames, and
|
||||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||||
* after `exit` or a policy close,
|
* after `exit` or a policy close,
|
||||||
* and recolours live on a dark-theme change without re-attaching.
|
* recolours live on a dark-theme change without re-attaching, draws through
|
||||||
|
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
|
||||||
|
* Alt+arrow word moves.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
import { TerminalView, loadXterm } from "../TerminalView";
|
render,
|
||||||
|
cleanup,
|
||||||
|
act,
|
||||||
|
createEvent,
|
||||||
|
fireEvent,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||||
|
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||||
|
|
||||||
const writeMock = vi.fn();
|
const writeMock = vi.fn();
|
||||||
const onDataHandlers: Array<(d: string) => void> = [];
|
const onDataHandlers: Array<(d: string) => void> = [];
|
||||||
@@ -27,11 +38,18 @@ const inputMock = vi.fn();
|
|||||||
let textarea: HTMLTextAreaElement;
|
let textarea: HTMLTextAreaElement;
|
||||||
const dropFileMock = vi.fn();
|
const dropFileMock = vi.fn();
|
||||||
const localPathsMock = vi.fn();
|
const localPathsMock = vi.fn();
|
||||||
|
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
|
||||||
|
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
|
||||||
|
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||||
|
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
run: {
|
run: {
|
||||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||||
localPaths: (paths: string[]) => localPathsMock(paths),
|
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||||
|
nativePaste: () => nativePasteMock(),
|
||||||
|
clipboardFiles: () => clipboardFilesMock(),
|
||||||
|
selectedFiles: () => selectedFilesMock(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -93,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
|
|||||||
vi.mock("@xterm/addon-fit", () => ({
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@xterm/addon-canvas", () => ({
|
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||||
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
|
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
|
||||||
|
last: null,
|
||||||
|
};
|
||||||
|
vi.mock("@xterm/addon-webgl", () => ({
|
||||||
|
WebglAddon: vi.fn().mockImplementation(() => {
|
||||||
|
let onLoss = () => {};
|
||||||
|
const addon = {
|
||||||
|
__webgl: true,
|
||||||
|
dispose: vi.fn(),
|
||||||
|
onContextLoss: (fn: () => void) => {
|
||||||
|
onLoss = fn;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
|
||||||
|
return addon;
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
@@ -120,6 +154,17 @@ class MockWebSocket {
|
|||||||
// @ts-expect-error test override
|
// @ts-expect-error test override
|
||||||
global.WebSocket = MockWebSocket;
|
global.WebSocket = MockWebSocket;
|
||||||
|
|
||||||
|
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
|
||||||
|
* tab); returns the restore. */
|
||||||
|
function withBox(width = 800, height = 600) {
|
||||||
|
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
|
||||||
|
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
|
||||||
|
return () => {
|
||||||
|
w.mockRestore();
|
||||||
|
h.mockRestore();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("TerminalView", () => {
|
describe("TerminalView", () => {
|
||||||
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||||
beforeAll(() => loadXterm());
|
beforeAll(() => loadXterm());
|
||||||
@@ -151,11 +196,51 @@ describe("TerminalView", () => {
|
|||||||
// it: tmux resizes the window to the newest client, so attaching at the
|
// it: tmux resizes the window to the newest client, so attaching at the
|
||||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||||
// leaves that width in the scrollback (issue #33).
|
// leaves that width in the scrollback (issue #33).
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
const restore = withBox();
|
||||||
expect(MockWebSocket.instances).toHaveLength(1);
|
try {
|
||||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
);
|
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||||
|
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a terminal on a hidden tab sends no size until it is shown", () => {
|
||||||
|
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
|
||||||
|
// that size resized the real tmux pane under every other client.
|
||||||
|
let observed: (() => void) | null = null;
|
||||||
|
const RO = globalThis.ResizeObserver;
|
||||||
|
// @ts-expect-error test override
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
constructor(cb: () => void) {
|
||||||
|
observed = cb;
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let restore = () => {};
|
||||||
|
try {
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||||
|
ws.onopen?.();
|
||||||
|
expect(ws.sent).toEqual([]);
|
||||||
|
// The tab is shown: the box gets a size and its size reaches tmux.
|
||||||
|
restore = withBox();
|
||||||
|
observed!();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(ws.sent).toEqual([
|
||||||
|
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
globalThis.ResizeObserver = RO;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||||
@@ -381,34 +466,75 @@ describe("TerminalView", () => {
|
|||||||
expect(onExit).toHaveBeenCalledTimes(1);
|
expect(onExit).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
it("renders through the WebGL addon, not xterm's DOM renderer", () => {
|
||||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||||
// fallback face with a different advance, so glyphs shove their neighbours
|
// fallback face with a different advance, so glyphs shove their neighbours
|
||||||
// and the row's tail spills onto the row below — buffer clean, painting
|
// and the row's tail spills onto the row below — buffer clean, painting
|
||||||
// wrong. The canvas renderer draws each cell at a fixed position instead.
|
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||||
|
// (xterm 6 removed the canvas one).
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe(
|
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const addon = gpu.last!;
|
||||||
|
expect(addon.dispose).not.toHaveBeenCalled();
|
||||||
|
addon.lose();
|
||||||
|
expect(addon.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||||
|
});
|
||||||
|
|
||||||
|
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||||
|
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||||
|
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||||
|
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
|
||||||
|
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
|
||||||
|
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
|
||||||
|
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
|
||||||
|
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
|
||||||
|
expect(altArrowSeq("ArrowUp", true)).toBeNull();
|
||||||
|
expect(altArrowSeq("a", false)).toBeNull();
|
||||||
|
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
|
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
|
||||||
|
expect(handler(ev)).toBe(false);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
|
||||||
|
// Another modifier with it is xterm's own sequence.
|
||||||
|
expect(
|
||||||
|
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||||
// next row's left margin, and tmux never repaints a row it believes
|
// next row's left margin, and tmux never repaints a row it believes
|
||||||
// unchanged — the stray characters glued to the left edge after scrolling.
|
// unchanged — the stray characters glued to the left edge after scrolling.
|
||||||
it("reports its size when the socket opens", () => {
|
it("reports its size when the socket opens", () => {
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
const restore = withBox();
|
||||||
const ws = MockWebSocket.instances[0]!;
|
try {
|
||||||
ws.onopen?.();
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(ws.sent).toContain(
|
const ws = MockWebSocket.instances[0]!;
|
||||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
ws.onopen?.();
|
||||||
);
|
expect(ws.sent).toContain(
|
||||||
|
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a size change xterm made on its own, with no container resize", async () => {
|
it("reports a size change xterm made on its own, with no container resize", async () => {
|
||||||
// Webfont load and page zoom both move cols without resizing the element
|
// Webfont load and page zoom both move cols without resizing the element
|
||||||
// ResizeObserver watches, which is why onResize is the hook.
|
// ResizeObserver watches, which is why onResize is the hook.
|
||||||
|
const restore = withBox();
|
||||||
|
onTestFinished(restore);
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
ws.onopen?.();
|
ws.onopen?.();
|
||||||
@@ -590,6 +716,168 @@ describe("TerminalView", () => {
|
|||||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
inputMock.mockClear();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
expect(pasteMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
|
||||||
|
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
|
||||||
|
// paste nothing, so the screenshot is uploaded like a remote browser's.
|
||||||
|
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
nativePasteMock.mockResolvedValueOnce({ native: false });
|
||||||
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
|
||||||
|
await Promise.resolve();
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
|
||||||
|
localStorage.setItem("agenthub.terminal.nativePaste", "off");
|
||||||
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||||
|
inputMock.mockClear();
|
||||||
|
try {
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem("agenthub.terminal.nativePaste");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
|
||||||
|
// the pane gets the file's own path off the OS clipboard — no upload, so
|
||||||
|
// no size limit (a 3 GB video too), as a native terminal pastes it.
|
||||||
|
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
inputMock.mockClear();
|
||||||
|
clipboardFilesMock.mockResolvedValueOnce({
|
||||||
|
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
|
||||||
|
});
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
|
||||||
|
Object.defineProperty(video, "size", { value: 3_000_000_000 });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [video], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chrome hands a file dragged from Explorer no path; the drag carries the
|
||||||
|
// items selected there, so the pane gets their own paths — as a native
|
||||||
|
// terminal types them — and nothing is uploaded, whatever the size.
|
||||||
|
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
selectedFilesMock.mockResolvedValueOnce({
|
||||||
|
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
|
||||||
|
});
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
const video = new File([new Uint8Array([1])], "big.mkv");
|
||||||
|
Object.defineProperty(video, "size", { value: 5_000_000_000 });
|
||||||
|
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||||
|
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads a dropped file the file manager does not hold selected", async () => {
|
||||||
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
|
||||||
|
selectedFilesMock.mockResolvedValueOnce({
|
||||||
|
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
|
||||||
|
});
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
|
||||||
|
const notes = new File(["x"], "notes.txt");
|
||||||
|
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||||
|
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A copied image FILE is not a screenshot: claude's own paste finds no
|
||||||
|
// picture on a clipboard holding a file, so it goes by path too.
|
||||||
|
it("pastes a copied image file by path instead of claude's image paste", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
inputMock.mockClear();
|
||||||
|
clipboardFilesMock.mockResolvedValueOnce({
|
||||||
|
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
|
||||||
|
});
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [logo], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cells from a spreadsheet come with a picture of them; a terminal pastes
|
||||||
|
// the text, so the browser's paste is left to xterm.
|
||||||
|
it("leaves text that comes with a picture of it to xterm's paste", async () => {
|
||||||
|
__resetNativePaste();
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
await waitFor(() => expect(nativePasteWorks()).toBe(true));
|
||||||
|
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
|
||||||
|
});
|
||||||
|
fireEvent(screen.getByTestId("terminal-view"), ev);
|
||||||
|
expect(ev.defaultPrevented).toBe(false);
|
||||||
|
expect(inputMock).not.toHaveBeenCalled();
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
||||||
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
||||||
// pasted like any browser paste: sending ^V for it pasted nothing.
|
// pasted like any browser paste: sending ^V for it pasted nothing.
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
* `#terminal-font`, with a preview line), and — local mode only — whether a
|
||||||
|
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
|
||||||
|
* `#native-paste`, this browser only); **Sessions** (`#sessions-view`) —
|
||||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||||
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
||||||
@@ -39,6 +41,8 @@ import {
|
|||||||
} from "../../lib/terminalFont";
|
} from "../../lib/terminalFont";
|
||||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||||||
|
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import {
|
import {
|
||||||
TERMINAL_FONT_SIZES,
|
TERMINAL_FONT_SIZES,
|
||||||
UI_FONT_SIZES,
|
UI_FONT_SIZES,
|
||||||
@@ -100,6 +104,8 @@ export function AppearanceSettings() {
|
|||||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||||
|
const [nativePaste, setNativePaste] = useState(readNativePaste);
|
||||||
|
const { local } = usePermissions();
|
||||||
const pickTermFont = (font: TerminalFont) => {
|
const pickTermFont = (font: TerminalFont) => {
|
||||||
writeTerminalFont(font);
|
writeTerminalFont(font);
|
||||||
setTermFont(font);
|
setTermFont(font);
|
||||||
@@ -209,6 +215,18 @@ export function AppearanceSettings() {
|
|||||||
>
|
>
|
||||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||||
</p>
|
</p>
|
||||||
|
{local && (
|
||||||
|
<SettingsSwitchRow
|
||||||
|
id="native-paste"
|
||||||
|
label={t("nativePaste.label")}
|
||||||
|
description={t("nativePaste.description")}
|
||||||
|
checked={nativePaste}
|
||||||
|
onChange={(on) => {
|
||||||
|
writeNativePaste(on);
|
||||||
|
setNativePaste(on);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @file ChangesPanelSettings — Settings → Git → Changes panel: whether a
|
||||||
|
* workspace folder that holds several git repositories shows each of them as
|
||||||
|
* its own section in the Changes panel (off by default: only the folder's own
|
||||||
|
* git). Writes the account preference in `lib/innerReposPref.ts`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useInnerReposMode, writeInnerReposMode } from "../../lib/innerReposPref";
|
||||||
|
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function ChangesPanelSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const mode = useInnerReposMode();
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("changesPanel.title")} description={t("changesPanel.description")}>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("changesPanel.innerRepos")}
|
||||||
|
description={t("changesPanel.innerReposDesc")}
|
||||||
|
checked={mode === "all"}
|
||||||
|
onChange={(on) => writeInnerReposMode(on ? "all" : "own")}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,14 +79,6 @@ function Step({ n, children }: { n: number; children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Code({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||||
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||||
|
|
||||||
@@ -642,10 +634,6 @@ export function GitIdentitySettings() {
|
|||||||
onRemove={() => removeKey("ed25519")}
|
onRemove={() => removeKey("ed25519")}
|
||||||
>
|
>
|
||||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
<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>
|
</KeyCard>
|
||||||
{namedOf("ed25519").map((k) => {
|
{namedOf("ed25519").map((k) => {
|
||||||
const rows = rowsOf(otherRows, k);
|
const rows = rowsOf(otherRows, k);
|
||||||
@@ -668,11 +656,6 @@ export function GitIdentitySettings() {
|
|||||||
{id.writable && rows.length === 0 && (
|
{id.writable && rows.length === 0 && (
|
||||||
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||||
)}
|
)}
|
||||||
{rows.map((r) => (
|
|
||||||
<Code key={r.alias ?? r.host}>
|
|
||||||
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
|
||||||
</Code>
|
|
||||||
))}
|
|
||||||
</Step>
|
</Step>
|
||||||
</KeyCard>
|
</KeyCard>
|
||||||
);
|
);
|
||||||
@@ -720,16 +703,6 @@ export function GitIdentitySettings() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Step>
|
</Step>
|
||||||
{rows.length > 0 && (
|
|
||||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
|
||||||
{t("gitIdentity.awsReady")}
|
|
||||||
{rows.map((r) => (
|
|
||||||
<Code key={r.alias ?? r.host}>
|
|
||||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
|
||||||
</Code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</KeyCard>
|
</KeyCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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: [
|
tools: [
|
||||||
tool("claude", "Claude Code"),
|
tool("claude", "Claude Code"),
|
||||||
tool("gemini", "Gemini"),
|
tool("gemini", "Gemini"),
|
||||||
tool("grok", "Grok", { enabled: false }),
|
tool("goose", "Goose", { enabled: false }),
|
||||||
tool("aider", "Aider", { installed: false, path: null }),
|
tool("aider", "Aider", { installed: false, path: null }),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
|
|||||||
await user.click(within(gemini).getByText("Set default"));
|
await user.click(within(gemini).getByText("Set default"));
|
||||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||||
// A disabled tool cannot become the default.
|
// A disabled tool cannot become the default.
|
||||||
const grok = screen.getByTestId("ai-tool-grok");
|
const goose = screen.getByTestId("ai-tool-goose");
|
||||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
expect(within(goose).getByText("Set default")).toBeDisabled();
|
||||||
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes detection", async () => {
|
it("refreshes detection", async () => {
|
||||||
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
|
|||||||
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
|
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
|
||||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||||
// A disabled tool cannot become the default from the menu either.
|
// A disabled tool cannot become the default from the menu either.
|
||||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-goose") });
|
||||||
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
|
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
describe("AiToolIcon", () => {
|
describe("AiToolIcon", () => {
|
||||||
it("retries the icon when the same instance shows another tool", async () => {
|
it("retries the icon when the same instance shows another tool", async () => {
|
||||||
const { AiToolIcon } = await import("../../AiToolIcon");
|
const { AiToolIcon } = await import("../../AiToolIcon");
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
|
||||||
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
|
|
||||||
);
|
|
||||||
fireEvent.error(container.querySelector("img")!);
|
fireEvent.error(container.querySelector("img")!);
|
||||||
expect(screen.getByText("O")).toBeInTheDocument();
|
expect(screen.getByText("A")).toBeInTheDocument();
|
||||||
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
|
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
|
||||||
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
|
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @file ChangesPanelSettings.test.tsx
|
||||||
|
* @description Settings → Git → Changes panel: the inner-repositories switch
|
||||||
|
* starts off, turning it on stores `all` in the synced account preference,
|
||||||
|
* and turning it off again removes the key (back to the default).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { ChangesPanelSettings } from "../ChangesPanelSettings";
|
||||||
|
import { INNER_REPOS_KEY, readInnerReposMode } from "../../../lib/innerReposPref";
|
||||||
|
|
||||||
|
describe("ChangesPanelSettings", () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("is off by default and toggles the account preference", () => {
|
||||||
|
render(<ChangesPanelSettings />);
|
||||||
|
const sw = screen.getByRole("switch", {
|
||||||
|
name: /Show the repositories inside the workspace folder/,
|
||||||
|
});
|
||||||
|
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||||
|
expect(readInnerReposMode()).toBe("own");
|
||||||
|
|
||||||
|
fireEvent.click(sw);
|
||||||
|
expect(localStorage.getItem(INNER_REPOS_KEY)).toBe("all");
|
||||||
|
expect(sw).toHaveAttribute("aria-checked", "true");
|
||||||
|
|
||||||
|
fireEvent.click(sw);
|
||||||
|
expect(localStorage.getItem(INNER_REPOS_KEY)).toBeNull();
|
||||||
|
expect(readInnerReposMode()).toBe("own");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -156,7 +156,6 @@ describe("GitIdentitySettings", () => {
|
|||||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||||
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||||
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
|
||||||
// A CodeCommit host never shows up again under "other hosts".
|
// A CodeCommit host never shows up again under "other hosts".
|
||||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||||
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||||
@@ -260,7 +259,6 @@ describe("GitIdentitySettings", () => {
|
|||||||
expect(
|
expect(
|
||||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
|
|
||||||
// The default key's card does not list the named key's login.
|
// The default key's card does not list the named key's login.
|
||||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||||
@@ -301,7 +299,7 @@ describe("GitIdentitySettings", () => {
|
|||||||
key: "id_ed25519_git-2",
|
key: "id_ed25519_git-2",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
await within(work).findByText("ssh://git-2/ → git@github.com")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
|
||||||
|
* 9router, a dialog that shows versions and starts the job, the server's
|
||||||
|
* blocker (with the manual command) disabling the start, and the job's steps.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { NineRouterUpdate } from "../NineRouterUpdate";
|
||||||
|
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
|
||||||
|
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
|
||||||
|
|
||||||
|
const base: NineRouterUpdateStatus = {
|
||||||
|
installed: true,
|
||||||
|
version: "0.5.104",
|
||||||
|
latest: "0.5.109",
|
||||||
|
updateAvailable: true,
|
||||||
|
running: true,
|
||||||
|
managedBy: "systemd",
|
||||||
|
blocker: null,
|
||||||
|
job: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const job: NineRouterUpdateJob = {
|
||||||
|
status: "running",
|
||||||
|
startedAt: "2026-10-26T00:00:00Z",
|
||||||
|
finishedAt: null,
|
||||||
|
from: "0.5.104",
|
||||||
|
to: null,
|
||||||
|
managedBy: "systemd",
|
||||||
|
install: "direct",
|
||||||
|
steps: [
|
||||||
|
{ id: "shutdown", status: "done", message: null },
|
||||||
|
{ id: "update", status: "running", message: null },
|
||||||
|
{ id: "relaunch", status: "pending", message: null },
|
||||||
|
],
|
||||||
|
log: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
statusMock.mockReset();
|
||||||
|
startMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NineRouterUpdate", () => {
|
||||||
|
it("renders nothing when 9router is not installed", async () => {
|
||||||
|
statusMock.mockResolvedValue({ ...base, installed: false });
|
||||||
|
const { container } = render(<NineRouterUpdate />);
|
||||||
|
await waitFor(() => expect(statusMock).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens with both versions and starts the three-step job", async () => {
|
||||||
|
statusMock.mockResolvedValue(base);
|
||||||
|
startMock.mockResolvedValue(job);
|
||||||
|
render(<NineRouterUpdate />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||||
|
expect(statusMock).toHaveBeenLastCalledWith(true);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(dialog).toHaveTextContent("0.5.104");
|
||||||
|
expect(dialog).toHaveTextContent("0.5.109");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
|
||||||
|
expect(startMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the blocker with its manual command and keeps the start disabled", async () => {
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
blocker: {
|
||||||
|
code: "ENOPERM",
|
||||||
|
message: "/usr/local is not writable by this account and sudo needs a password",
|
||||||
|
command: "sudo npm install -g 9router@latest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<NineRouterUpdate />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||||
|
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @file useCcTarget — whose Claude Code config the Config Explorer manages for
|
||||||
|
* this login (GET /api/cc-config/target). Local mode and an admin: the host's,
|
||||||
|
* answered at once without a request (nothing changes for them). A non-admin
|
||||||
|
* in server mode asks the server: `own` when they have their own Linux account
|
||||||
|
* (S3) — Settings then shows "Your Claude Code config" — else `none` and the
|
||||||
|
* page stays hidden. The server's refusal (403 ENOACCOUNT/EADMIN) is the rule;
|
||||||
|
* this only hides what would be refused.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type CcTarget } from "../lib/api";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
|
|
||||||
|
const HOST: CcTarget = { kind: "host", readOnly: false, claudeHome: null };
|
||||||
|
|
||||||
|
/** `null` while a non-admin's answer is on its way. */
|
||||||
|
export function useCcTarget(): CcTarget | null {
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
const [target, setTarget] = useState<CcTarget | null>(isAdmin ? HOST : null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
setTarget(HOST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
api.ccConfig
|
||||||
|
.target()
|
||||||
|
.then((t) => {
|
||||||
|
if (live) setTarget(t);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (live) setTarget({ kind: "none", readOnly: true, claudeHome: null });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [isAdmin]);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@@ -27,9 +27,9 @@ const FOCUSABLE =
|
|||||||
*/
|
*/
|
||||||
export function useDialog(
|
export function useDialog(
|
||||||
open: boolean,
|
open: boolean,
|
||||||
panelRef: RefObject<HTMLElement>,
|
panelRef: RefObject<HTMLElement | null>,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
initialFocus?: RefObject<HTMLElement>
|
initialFocus?: RefObject<HTMLElement | null>
|
||||||
): void {
|
): void {
|
||||||
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
||||||
// would steal focus back to the first control on every parent render.
|
// would steal focus back to the first control on every parent render.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type MessageHandler = (msg: WSMessage) => void;
|
|||||||
* auto-reconnects with capped exponential backoff on close - plus an
|
* auto-reconnects with capped exponential backoff on close - plus an
|
||||||
* immediate reconnect attempt on tab focus/network-online/visibility-change
|
* immediate reconnect attempt on tab focus/network-online/visibility-change
|
||||||
* so the socket recovers quickly after a server restart or laptop sleep.
|
* so the socket recovers quickly after a server restart or laptop sleep.
|
||||||
* Guards against React 18 StrictMode's mount→cleanup→remount cycle opening a
|
* Guards against React StrictMode's mount→cleanup→remount cycle opening a
|
||||||
* duplicate socket (see the inline comment in `connect`).
|
* duplicate socket (see the inline comment in `connect`).
|
||||||
* @param onMessage Called with every message parsed from the socket; the
|
* @param onMessage Called with every message parsed from the socket; the
|
||||||
* latest reference is used even across reconnects (no stale closures).
|
* latest reference is used even across reconnects (no stale closures).
|
||||||
@@ -73,7 +73,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const handlersRef = useRef<MessageHandler>(onMessage);
|
const handlersRef = useRef<MessageHandler>(onMessage);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const reconnectAttempts = useRef(0);
|
const reconnectAttempts = useRef(0);
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
|||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
// Don't open a second socket if one is already alive or in flight.
|
// Don't open a second socket if one is already alive or in flight.
|
||||||
// Without this, React 18 StrictMode (mount → cleanup → remount in dev)
|
// Without this, React StrictMode (mount → cleanup → remount in dev)
|
||||||
// and the close→reconnect race could leave two sockets connected at the
|
// and the close→reconnect race could leave two sockets connected at the
|
||||||
// same time. Both would receive every server broadcast, producing
|
// same time. Both would receive every server broadcast, producing
|
||||||
// duplicate stream_event deltas (doubled text, duplicate assistant
|
// duplicate stream_event deltas (doubled text, duplicate assistant
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"noPreview": "This file type cannot be shown here — download it to open it.",
|
"noPreview": "This file type cannot be shown here — download it to open it.",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
"installs": "{{count}} installs",
|
|
||||||
"installed": "Installed to {{path}}",
|
"installed": "Installed to {{path}}",
|
||||||
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
||||||
"rename": "Edit",
|
"rename": "Edit",
|
||||||
@@ -197,5 +196,32 @@
|
|||||||
"unowned.claimed": "Added to your library",
|
"unowned.claimed": "Added to your library",
|
||||||
"unowned.declined": "Removed from your list",
|
"unowned.declined": "Removed from your list",
|
||||||
"unowned.assigned": "Owner assigned",
|
"unowned.assigned": "Owner assigned",
|
||||||
"unowned.empty": "Nothing without an owner."
|
"unowned.empty": "Nothing without an owner.",
|
||||||
|
"installs_one": "{{count}} install",
|
||||||
|
"installs_other": "{{count}} installs",
|
||||||
|
"private": "Private",
|
||||||
|
"privateHint": "Not shared: only you (and admins) see it. Share puts it in the Hub.",
|
||||||
|
"showMore": "Show {{count}} more",
|
||||||
|
"view.label": "Layout",
|
||||||
|
"view.list": "List view",
|
||||||
|
"view.grid": "Grid view",
|
||||||
|
"pack.title": "{{prefix}}-* pack",
|
||||||
|
"pack.count_one": "{{count}} skill",
|
||||||
|
"pack.count_other": "{{count}} skills",
|
||||||
|
"pack.installAll": "Install all",
|
||||||
|
"pack.installRest": "Install {{count}} more",
|
||||||
|
"pack.installTitle": "Install {{count}} skills of {{prefix}}-*?",
|
||||||
|
"pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:",
|
||||||
|
"pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}",
|
||||||
|
"section.unshared": "Not shared by their owners",
|
||||||
|
"section.unsharedHint": "Other users' private items — only admins see these",
|
||||||
|
"update": "Update",
|
||||||
|
"updateHint": "Its owner changed it since you installed it",
|
||||||
|
"updateTitle": "Update “{{title}}”?",
|
||||||
|
"updateBody": "Your copy of {{name}} is replaced by the newest version. The current one is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||||
|
"updated": "Updated {{name}}",
|
||||||
|
"pack.updateN": "Update {{count}}",
|
||||||
|
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
||||||
|
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
||||||
|
"pack.updated": "Updated {{ok}} · failed {{failed}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "Claude Config Explorer",
|
"title": "Claude Config Explorer",
|
||||||
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
|
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
|
||||||
|
"own": {
|
||||||
|
"label": "Your Claude Code config — {{home}}",
|
||||||
|
"subtitle": "Only your own account's setup: every change here runs as your account and reaches only your own sessions."
|
||||||
|
},
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"refreshing": "Refreshing…",
|
"refreshing": "Refreshing…",
|
||||||
"lastUpdated": "Last updated {{time}}",
|
"lastUpdated": "Last updated {{time}}",
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
|
"claudeHome": "Your .claude",
|
||||||
"projectClaudeDir": "Project .claude",
|
"projectClaudeDir": "Project .claude",
|
||||||
"projectRoot": "Project root",
|
"projectRoot": "Project root",
|
||||||
"claudeJson": "claude.json",
|
"claudeJson": "claude.json",
|
||||||
|
|||||||
@@ -657,5 +657,23 @@
|
|||||||
"fx.readOnly": "Read-only",
|
"fx.readOnly": "Read-only",
|
||||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
||||||
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
|
"tmuxUpgrade": "Upgrade tmux: {{advice}}",
|
||||||
|
"changes.rescanRepos": "Scan for repositories again",
|
||||||
|
"tree.newGroup": "New group",
|
||||||
|
"tree.groupDefaultName": "New group",
|
||||||
|
"tree.ungrouped": "Ungrouped",
|
||||||
|
"tree.groupToggle": "Show or hide the {{name}} group",
|
||||||
|
"tree.groupMenu": "Group options",
|
||||||
|
"tree.groupRename": "Rename group",
|
||||||
|
"tree.groupDelete": "Delete group",
|
||||||
|
"tree.groupMoveUp": "Move group up",
|
||||||
|
"tree.groupMoveDown": "Move group down",
|
||||||
|
"tree.groupDeleteConfirm": "Delete the group \"{{name}}\"? Its workspaces stay, ungrouped.",
|
||||||
|
"tree.groupNameLabel": "Group name",
|
||||||
|
"menu.moveToGroup": "Move to group",
|
||||||
|
"menu.removeFromGroup": "Remove from group",
|
||||||
|
"menu.newGroupWith": "New group…",
|
||||||
|
"tree.groupAddWorkspace": "New workspace in this group…",
|
||||||
|
"tree.groupFoldAll": "Fold all groups",
|
||||||
|
"tree.groupUnfoldAll": "Unfold all groups"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,5 +88,6 @@
|
|||||||
"now": "now",
|
"now": "now",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"admin": "Administration",
|
"admin": "Administration",
|
||||||
"files": "Files"
|
"files": "Files",
|
||||||
|
"snapshot": "Snapshot"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"failed": "Could not attach the file",
|
"failed": "Could not attach the file",
|
||||||
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
||||||
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
||||||
|
"tooBigLocalPaste": "File too large to upload (max {{mb}} MB) — copy it in your file manager and paste it here instead (Ctrl+V): a pasted file goes in by its own path",
|
||||||
"remove": "Remove attachment",
|
"remove": "Remove attachment",
|
||||||
"view": "View image"
|
"view": "View image"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.",
|
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||||
"add": "Add profile",
|
"add": "Add profile",
|
||||||
"config9router": "Config 9router",
|
"config9router": "Config 9router",
|
||||||
|
"update9router": "Update 9router",
|
||||||
|
"update9routerAvailable": "A newer 9router is available",
|
||||||
|
"update9routerCurrent": "Installed",
|
||||||
|
"update9routerLatest": "Latest",
|
||||||
|
"update9routerState": "State",
|
||||||
|
"update9routerRunning": "Running",
|
||||||
|
"update9routerRunningUnit": "Running (9router.service)",
|
||||||
|
"update9routerStopped": "Not running",
|
||||||
|
"update9routerExplain": "Every session using 9router is disconnected while it updates: 9router is shut down, updated with npm, then started again hidden (tray, no browser).",
|
||||||
|
"update9routerExplainStopped": "9router is not running: it is updated with npm and left off.",
|
||||||
|
"update9routerStep": {
|
||||||
|
"shutdown": "Disconnect and shut down 9router",
|
||||||
|
"update": "Run the update (npm install -g 9router@latest)",
|
||||||
|
"relaunch": "Start 9router again, hidden"
|
||||||
|
},
|
||||||
|
"update9routerDone": "9router {{version}} is running.",
|
||||||
|
"update9routerDoneStopped": "9router {{version}} is installed and left off, as it was.",
|
||||||
|
"update9routerFailed": "The update did not finish — see the steps and the log.",
|
||||||
|
"update9routerLog": "npm output",
|
||||||
|
"update9routerUpToDate": "9router is already the latest version.",
|
||||||
|
"update9routerCopy": "Copy command",
|
||||||
|
"update9routerClose": "Close",
|
||||||
|
"update9routerStart": "Update now",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||||
@@ -884,7 +907,6 @@
|
|||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
"gitHubSub": "ED25519 key — the git@… SSH address",
|
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||||
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
||||||
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
|
|
||||||
"awsTitle": "AWS CodeCommit",
|
"awsTitle": "AWS CodeCommit",
|
||||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||||
"stepCopy": "Copy the public key.",
|
"stepCopy": "Copy the public key.",
|
||||||
@@ -894,7 +916,6 @@
|
|||||||
"awsRegion": "Region",
|
"awsRegion": "Region",
|
||||||
"awsSave": "Save",
|
"awsSave": "Save",
|
||||||
"awsSaved": "SSH key ID saved for {{region}}",
|
"awsSaved": "SSH key ID saved for {{region}}",
|
||||||
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
|
|
||||||
"otherTitle": "Other SSH hosts (advanced)",
|
"otherTitle": "Other SSH hosts (advanced)",
|
||||||
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
||||||
"otherHost": "Host",
|
"otherHost": "Host",
|
||||||
@@ -1035,6 +1056,10 @@
|
|||||||
"title": "Claude Code",
|
"title": "Claude Code",
|
||||||
"description": "Claude Code's own configuration, and what AgentHub reads from it."
|
"description": "Claude Code's own configuration, and what AgentHub reads from it."
|
||||||
},
|
},
|
||||||
|
"claudeCodeOwn": {
|
||||||
|
"title": "Your Claude Code config",
|
||||||
|
"description": "The skills, agents, plugins, MCP servers and settings your own sessions use."
|
||||||
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"title": "Codex",
|
"title": "Codex",
|
||||||
"description": "Codex's own configuration, and what AgentHub reads from it."
|
"description": "Codex's own configuration, and what AgentHub reads from it."
|
||||||
@@ -1075,6 +1100,12 @@
|
|||||||
"noMatch": "No setting matches “{{query}}”.",
|
"noMatch": "No setting matches “{{query}}”.",
|
||||||
"results": "Search results"
|
"results": "Search results"
|
||||||
},
|
},
|
||||||
|
"changesPanel": {
|
||||||
|
"title": "Changes panel",
|
||||||
|
"description": "For a workspace folder that holds several git repositories (each with its own .git). Saved to your account.",
|
||||||
|
"innerRepos": "Show the repositories inside the workspace folder",
|
||||||
|
"innerReposDesc": "Each repository found inside the folder (up to two levels down) gets its own section in the Changes panel. Off: only the folder's own git is shown."
|
||||||
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Worktree suggestions",
|
"title": "Worktree suggestions",
|
||||||
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
|
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
|
||||||
@@ -1134,6 +1165,10 @@
|
|||||||
"18": "Larger"
|
"18": "Larger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nativePaste": {
|
||||||
|
"label": "Paste images natively",
|
||||||
|
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Needs wl-paste or xclip on Linux; without them the image is uploaded instead. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
|
||||||
|
},
|
||||||
"terminalFont": {
|
"terminalFont": {
|
||||||
"title": "Terminal font",
|
"title": "Terminal font",
|
||||||
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||||
"autoFixWorkspace": "AgentHub update",
|
"autoFixWorkspace": "AgentHub update",
|
||||||
"autoFixError": "Could not start the Auto fix session",
|
"autoFixError": "Could not start the Auto fix session",
|
||||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
|
|
||||||
"chipTitle": "Updating to {{version}}…",
|
"chipTitle": "Updating to {{version}}…",
|
||||||
"chipHint": "The update runs in the background — click to show it",
|
"chipHint": "The update runs in the background — click to show it",
|
||||||
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||||
|
|||||||
@@ -197,5 +197,29 @@
|
|||||||
"unowned.claimed": "Đã nhận về thư viện của bạn",
|
"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.declined": "Đã bỏ khỏi danh sách của bạn",
|
||||||
"unowned.assigned": "Đã gán chủ",
|
"unowned.assigned": "Đã gán chủ",
|
||||||
"unowned.empty": "Không có mục nào chưa có chủ."
|
"unowned.empty": "Không có mục nào chưa có chủ.",
|
||||||
|
"private": "Riêng tư",
|
||||||
|
"privateHint": "Chưa chia sẻ: chỉ bạn (và admin) thấy. Bấm Chia sẻ để đưa lên Hub.",
|
||||||
|
"showMore": "Xem thêm {{count}}",
|
||||||
|
"view.label": "Cách hiển thị",
|
||||||
|
"view.list": "Dạng danh sách",
|
||||||
|
"view.grid": "Dạng lưới",
|
||||||
|
"pack.title": "Bộ {{prefix}}-*",
|
||||||
|
"pack.count": "{{count}} skill",
|
||||||
|
"pack.installAll": "Cài tất cả",
|
||||||
|
"pack.installRest": "Cài {{count}} còn lại",
|
||||||
|
"pack.installTitle": "Cài {{count}} skill của bộ {{prefix}}-*?",
|
||||||
|
"pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:",
|
||||||
|
"pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}",
|
||||||
|
"section.unshared": "Chủ chưa chia sẻ",
|
||||||
|
"section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy",
|
||||||
|
"update": "Cập nhật",
|
||||||
|
"updateHint": "Người tạo đã sửa skill này từ khi bạn cài",
|
||||||
|
"updateTitle": "Cập nhật “{{title}}”?",
|
||||||
|
"updateBody": "Bản {{name}} của bạn sẽ được thay bằng phiên bản mới nhất. Bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/, không bị xóa.",
|
||||||
|
"updated": "Đã cập nhật {{name}}",
|
||||||
|
"pack.updateN": "Cập nhật {{count}}",
|
||||||
|
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
||||||
|
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
||||||
|
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "Trình khám phá cấu hình Claude",
|
"title": "Trình khám phá cấu hình Claude",
|
||||||
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
|
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
|
||||||
|
"own": {
|
||||||
|
"label": "Cấu hình Claude Code của bạn — {{home}}",
|
||||||
|
"subtitle": "Chỉ thiết lập của tài khoản bạn: mọi thay đổi ở đây chạy dưới tài khoản của bạn và chỉ áp dụng cho các phiên của chính bạn."
|
||||||
|
},
|
||||||
"refresh": "Làm mới",
|
"refresh": "Làm mới",
|
||||||
"refreshing": "Đang làm mới…",
|
"refreshing": "Đang làm mới…",
|
||||||
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
|
"claudeHome": ".claude của bạn",
|
||||||
"projectClaudeDir": "Dự án .claude",
|
"projectClaudeDir": "Dự án .claude",
|
||||||
"projectRoot": "Thư mục dự án",
|
"projectRoot": "Thư mục dự án",
|
||||||
"claudeJson": "claude.json",
|
"claudeJson": "claude.json",
|
||||||
|
|||||||
@@ -657,5 +657,23 @@
|
|||||||
"fx.readOnly": "Chỉ đọc",
|
"fx.readOnly": "Chỉ đọc",
|
||||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
||||||
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
|
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}",
|
||||||
|
"changes.rescanRepos": "Quét lại các repository",
|
||||||
|
"tree.newGroup": "Nhóm mới",
|
||||||
|
"tree.groupDefaultName": "Nhóm mới",
|
||||||
|
"tree.ungrouped": "Chưa phân nhóm",
|
||||||
|
"tree.groupToggle": "Hiện hoặc ẩn nhóm {{name}}",
|
||||||
|
"tree.groupMenu": "Tuỳ chọn nhóm",
|
||||||
|
"tree.groupRename": "Đổi tên nhóm",
|
||||||
|
"tree.groupDelete": "Xoá nhóm",
|
||||||
|
"tree.groupMoveUp": "Đưa nhóm lên",
|
||||||
|
"tree.groupMoveDown": "Đưa nhóm xuống",
|
||||||
|
"tree.groupDeleteConfirm": "Xoá nhóm \"{{name}}\"? Các workspace trong nhóm vẫn giữ nguyên, chỉ không còn thuộc nhóm.",
|
||||||
|
"tree.groupNameLabel": "Tên nhóm",
|
||||||
|
"menu.moveToGroup": "Chuyển vào nhóm",
|
||||||
|
"menu.removeFromGroup": "Bỏ khỏi nhóm",
|
||||||
|
"menu.newGroupWith": "Nhóm mới…",
|
||||||
|
"tree.groupAddWorkspace": "Workspace mới trong nhóm này…",
|
||||||
|
"tree.groupFoldAll": "Thu gọn mọi nhóm",
|
||||||
|
"tree.groupUnfoldAll": "Mở rộng mọi nhóm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,5 +88,6 @@
|
|||||||
"now": "bây giờ",
|
"now": "bây giờ",
|
||||||
"users": "Người dùng",
|
"users": "Người dùng",
|
||||||
"admin": "Quản trị",
|
"admin": "Quản trị",
|
||||||
"files": "Quản lý file"
|
"files": "Quản lý file",
|
||||||
|
"snapshot": "Snapshot"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"failed": "Không đính kèm được file",
|
"failed": "Không đính kèm được file",
|
||||||
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
||||||
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
||||||
|
"tooBigLocalPaste": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy copy file trong trình quản lý file rồi dán vào đây (Ctrl+V): file được dán sẽ dùng đường dẫn gốc",
|
||||||
"remove": "Bỏ đính kèm",
|
"remove": "Bỏ đính kèm",
|
||||||
"view": "Xem ảnh"
|
"view": "Xem ảnh"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.",
|
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||||
"add": "Thêm profile",
|
"add": "Thêm profile",
|
||||||
"config9router": "Config 9router",
|
"config9router": "Config 9router",
|
||||||
|
"update9router": "Cập nhật 9router",
|
||||||
|
"update9routerAvailable": "Đã có bản 9router mới",
|
||||||
|
"update9routerCurrent": "Đang cài",
|
||||||
|
"update9routerLatest": "Mới nhất",
|
||||||
|
"update9routerState": "Trạng thái",
|
||||||
|
"update9routerRunning": "Đang chạy",
|
||||||
|
"update9routerRunningUnit": "Đang chạy (9router.service)",
|
||||||
|
"update9routerStopped": "Không chạy",
|
||||||
|
"update9routerExplain": "Mọi session đang dùng 9router sẽ bị ngắt kết nối trong lúc cập nhật: 9router được tắt, cập nhật bằng npm rồi mở lại ở chế độ ẩn (khay hệ thống, không mở trình duyệt).",
|
||||||
|
"update9routerExplainStopped": "9router đang không chạy: chỉ cập nhật bằng npm và giữ nguyên trạng thái tắt.",
|
||||||
|
"update9routerStep": {
|
||||||
|
"shutdown": "Ngắt kết nối và tắt 9router",
|
||||||
|
"update": "Chạy cập nhật (npm install -g 9router@latest)",
|
||||||
|
"relaunch": "Mở lại 9router ở chế độ ẩn"
|
||||||
|
},
|
||||||
|
"update9routerDone": "9router {{version}} đang chạy.",
|
||||||
|
"update9routerDoneStopped": "Đã cài 9router {{version}}, giữ nguyên trạng thái tắt như trước.",
|
||||||
|
"update9routerFailed": "Cập nhật chưa hoàn tất — xem các bước và log.",
|
||||||
|
"update9routerLog": "Output của npm",
|
||||||
|
"update9routerUpToDate": "9router đã là bản mới nhất.",
|
||||||
|
"update9routerCopy": "Sao chép lệnh",
|
||||||
|
"update9routerClose": "Đóng",
|
||||||
|
"update9routerStart": "Cập nhật ngay",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"remove": "Xoá",
|
"remove": "Xoá",
|
||||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||||
@@ -872,7 +895,6 @@
|
|||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||||
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
||||||
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
|
|
||||||
"awsTitle": "AWS CodeCommit",
|
"awsTitle": "AWS CodeCommit",
|
||||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||||
"stepCopy": "Copy public key.",
|
"stepCopy": "Copy public key.",
|
||||||
@@ -882,7 +904,6 @@
|
|||||||
"awsRegion": "Region",
|
"awsRegion": "Region",
|
||||||
"awsSave": "Lưu",
|
"awsSave": "Lưu",
|
||||||
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
||||||
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
|
|
||||||
"otherTitle": "Host SSH khác (nâng cao)",
|
"otherTitle": "Host SSH khác (nâng cao)",
|
||||||
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
||||||
"otherHost": "Host",
|
"otherHost": "Host",
|
||||||
@@ -1023,6 +1044,10 @@
|
|||||||
"title": "Claude Code",
|
"title": "Claude Code",
|
||||||
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
|
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
|
||||||
},
|
},
|
||||||
|
"claudeCodeOwn": {
|
||||||
|
"title": "Cấu hình Claude Code của bạn",
|
||||||
|
"description": "Skill, agent, plugin, MCP server và thiết lập mà các phiên của chính bạn dùng."
|
||||||
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"title": "Codex",
|
"title": "Codex",
|
||||||
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
|
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
|
||||||
@@ -1063,6 +1088,12 @@
|
|||||||
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
|
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
|
||||||
"results": "Kết quả tìm kiếm"
|
"results": "Kết quả tìm kiếm"
|
||||||
},
|
},
|
||||||
|
"changesPanel": {
|
||||||
|
"title": "Panel Changes",
|
||||||
|
"description": "Dành cho thư mục workspace chứa nhiều repo git (mỗi repo có .git riêng). Lưu theo tài khoản của bạn.",
|
||||||
|
"innerRepos": "Hiện các repo bên trong thư mục workspace",
|
||||||
|
"innerReposDesc": "Mỗi repo tìm thấy trong thư mục (sâu tối đa hai cấp) có một mục riêng trong panel Changes. Tắt: chỉ hiện git của chính thư mục."
|
||||||
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Gợi ý worktree",
|
"title": "Gợi ý worktree",
|
||||||
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
|
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
|
||||||
@@ -1122,6 +1153,10 @@
|
|||||||
"18": "Rất lớn"
|
"18": "Rất lớn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nativePaste": {
|
||||||
|
"label": "Dán ảnh kiểu native",
|
||||||
|
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Trên Linux cần wl-paste hoặc xclip; thiếu thì ảnh được upload thay thế. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
|
||||||
|
},
|
||||||
"terminalFont": {
|
"terminalFont": {
|
||||||
"title": "Font terminal",
|
"title": "Font terminal",
|
||||||
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||||
"autoFixError": "Không mở được phiên Tự sửa",
|
"autoFixError": "Không mở được phiên Tự sửa",
|
||||||
"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}}…",
|
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||||
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
"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.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||||
|
|||||||
@@ -717,6 +717,11 @@
|
|||||||
.xterm-viewport::-webkit-scrollbar {
|
.xterm-viewport::-webkit-scrollbar {
|
||||||
display: none;
|
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.
|
/* 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]`
|
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);
|
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", () => {
|
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||||
const msgs = [
|
const msgs = [
|
||||||
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
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.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
|
||||||
await api.lanes.git(gitTarget(3, null));
|
await api.lanes.git(gitTarget(3, null));
|
||||||
const urls = spy.mock.calls.map((c) => String(c[0]));
|
const urls = spy.mock.calls.map((c) => String(c[0]));
|
||||||
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
|
expect(urls[0]).toMatch(/\/workspaces\/3\/git\/changes\?range=work$/);
|
||||||
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
|
expect(urls[1]).toMatch(/\/workspaces\/3\/git\/changes\?range=work&session=s-1$/);
|
||||||
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
|
expect(urls[2]).toMatch(/\/workspaces\/3\/git\/stage\?session=s-1$/);
|
||||||
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
|
expect(urls[3]).toMatch(/\/workspaces\/3\/git$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function stubFetch(body: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("api.lanes.git", () => {
|
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 = {
|
const facts = {
|
||||||
available: true,
|
available: true,
|
||||||
branch: "feat/x",
|
branch: "feat/x",
|
||||||
@@ -41,7 +41,7 @@ describe("api.lanes.git", () => {
|
|||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
|
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
|
||||||
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
|
// 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(call[1]?.method ?? "GET").toBe("GET");
|
||||||
expect(result).toEqual(facts);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+158
-48
@@ -895,11 +895,17 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
/**
|
/**
|
||||||
* What a workspace git call works on: the workspace (its id) or, with
|
* What a workspace git call works on: the workspace (its id) or, with
|
||||||
* `session`, that session's own worktree (`claude --worktree`), or, with
|
* `session`, that session's own worktree (`claude --worktree`), or, with
|
||||||
* `source`, one of the workspace's extra folders — the server resolves the
|
* `source`, one of the workspace's extra folders, or with `repo`, a repo
|
||||||
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
|
* inside the workspace folder — the server resolves the tree from the session
|
||||||
* never from a path.
|
* 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=`. */
|
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
||||||
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||||
@@ -907,8 +913,9 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
|||||||
const lane = typeof target === "number" ? target : target.lane;
|
const lane = typeof target === "number" ? target : target.lane;
|
||||||
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
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.source) params.set("source", String(target.source));
|
||||||
|
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
|
||||||
const qs = params.toString();
|
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. */
|
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
|
||||||
@@ -922,7 +929,7 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
|||||||
const qs = new URLSearchParams(q).toString();
|
const qs = new URLSearchParams(q).toString();
|
||||||
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1005,16 +1012,24 @@ export interface FsTrashItem {
|
|||||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||||
export type FsConflict = "fail" | "skip" | "keep" | "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(
|
export function gitTarget(
|
||||||
laneId: number,
|
laneId: number,
|
||||||
session?: string | null,
|
session?: string | null,
|
||||||
source?: number | null
|
source?: GitSource | null
|
||||||
): GitTarget {
|
): 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;
|
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 = {
|
export const api = {
|
||||||
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
|
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
|
||||||
auth: {
|
auth: {
|
||||||
@@ -1995,7 +2010,7 @@ export const api = {
|
|||||||
purged_agents: number;
|
purged_agents: number;
|
||||||
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
|
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
|
||||||
gc: (body: { dryRun?: boolean } = {}) =>
|
gc: (body: { dryRun?: boolean } = {}) =>
|
||||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
|
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/workspaces/gc", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -2144,7 +2159,9 @@ export const api = {
|
|||||||
*
|
*
|
||||||
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
|
* @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 /
|
// Scope the aggregate to the active data-scope, exactly like the sessions /
|
||||||
// stats / analytics endpoints — otherwise switching the Data scope selector
|
// stats / analytics endpoints — otherwise switching the Data scope selector
|
||||||
// left the Dashboard "total cost" showing the un-narrowed global total.
|
// left the Dashboard "total cost" showing the un-narrowed global total.
|
||||||
@@ -2154,6 +2171,8 @@ export const api = {
|
|||||||
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
|
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
|
||||||
if (opts.date) qs.set("date", opts.date);
|
if (opts.date) qs.set("date", opts.date);
|
||||||
if (opts.tool) qs.set("tool", opts.tool);
|
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.
|
// `range`: total, breakdown and by_tool for those local days only.
|
||||||
applyRange(qs, opts.range);
|
applyRange(qs, opts.range);
|
||||||
applyScope(qs);
|
applyScope(qs);
|
||||||
@@ -2278,6 +2297,13 @@ export const api = {
|
|||||||
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
|
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
|
||||||
* script - for the dashboard's "CC Config" explorer/editor pages. */
|
* script - for the dashboard's "CC Config" explorer/editor pages. */
|
||||||
ccConfig: {
|
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
|
* GET /api/cc-config/overview - counts of every artifact kind, for the
|
||||||
* explorer's landing page.
|
* explorer's landing page.
|
||||||
@@ -3135,6 +3161,13 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ replace }),
|
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`. */
|
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
|
||||||
uninstall: (id: number) =>
|
uninstall: (id: number) =>
|
||||||
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
|
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
|
||||||
@@ -3248,6 +3281,12 @@ export const api = {
|
|||||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||||
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||||
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
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. */
|
/** POST /api/ai-proxies - save a new profile. */
|
||||||
create: (body: AiProxyInput) =>
|
create: (body: AiProxyInput) =>
|
||||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||||
@@ -3335,6 +3374,27 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ paths }),
|
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. */
|
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||||
start: (args: RunStartArgs) =>
|
start: (args: RunStartArgs) =>
|
||||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||||
@@ -3743,7 +3803,7 @@ export const api = {
|
|||||||
* GET /api/lanes — list all lanes with pipeline progress and liveness.
|
* GET /api/lanes — list all lanes with pipeline progress and liveness.
|
||||||
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
|
* @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.
|
* 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
|
* @param body The cwd and optional title. When `cwd` is omitted the server
|
||||||
@@ -3759,7 +3819,7 @@ export const api = {
|
|||||||
/** Only a lane on THIS directory counts, not one on a parent of it. */
|
/** Only a lane on THIS directory counts, not one on a parent of it. */
|
||||||
exact?: boolean;
|
exact?: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
|
request<{ lane: Lane; created: boolean }>("/workspaces/ensure", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -3782,7 +3842,7 @@ export const api = {
|
|||||||
/** This clone's name for {@link lanes.cloneProgress}. */
|
/** This clone's name for {@link lanes.cloneProgress}. */
|
||||||
progressToken?: string;
|
progressToken?: string;
|
||||||
}) =>
|
}) =>
|
||||||
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -3792,7 +3852,7 @@ export const api = {
|
|||||||
* carrying `progressToken` arrives, and a minute after it ends.
|
* carrying `progressToken` arrives, and a minute after it ends.
|
||||||
*/
|
*/
|
||||||
cloneProgress: (token: string) =>
|
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
|
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||||
@@ -3800,7 +3860,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
pipelines: () =>
|
pipelines: () =>
|
||||||
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
|
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
|
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
|
||||||
@@ -3810,7 +3870,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
pipelineStats: (laneId?: number | null) =>
|
pipelineStats: (laneId?: number | null) =>
|
||||||
request<PipelineStats>(
|
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.
|
* GET /api/lanes/branches — a candidate source repo's local branches.
|
||||||
@@ -3822,7 +3882,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
branches: (repo: string) =>
|
branches: (repo: string) =>
|
||||||
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
|
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
|
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as
|
||||||
@@ -3840,7 +3900,7 @@ export const api = {
|
|||||||
pipeline?: string;
|
pipeline?: string;
|
||||||
tool?: LaneTool;
|
tool?: LaneTool;
|
||||||
}) =>
|
}) =>
|
||||||
request<{ lane: Lane }>("/lanes/worktree", {
|
request<{ lane: Lane }>("/workspaces/worktree", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -3875,7 +3935,7 @@ export const api = {
|
|||||||
file?: boolean;
|
file?: boolean;
|
||||||
mtime?: number | null;
|
mtime?: number | null;
|
||||||
}[];
|
}[];
|
||||||
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
}>(`/workspaces/browse${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
||||||
@@ -3883,7 +3943,7 @@ export const api = {
|
|||||||
* `EFOLDER` (outside the caller's own folder).
|
* `EFOLDER` (outside the caller's own folder).
|
||||||
*/
|
*/
|
||||||
mkdir: (parent: string, name: string) =>
|
mkdir: (parent: string, name: string) =>
|
||||||
request<{ path: string }>("/lanes/browse/mkdir", {
|
request<{ path: string }>("/workspaces/browse/mkdir", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ parent, name }),
|
body: JSON.stringify({ parent, name }),
|
||||||
}),
|
}),
|
||||||
@@ -3897,7 +3957,7 @@ export const api = {
|
|||||||
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
|
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
|
||||||
*/
|
*/
|
||||||
pickPath: (kind: "dir" | "file", start?: string) =>
|
pickPath: (kind: "dir" | "file", start?: string) =>
|
||||||
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
|
request<{ path?: string; cancelled?: boolean }>("/workspaces/pick-path", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ kind, start }),
|
body: JSON.stringify({ kind, start }),
|
||||||
}),
|
}),
|
||||||
@@ -3906,7 +3966,7 @@ export const api = {
|
|||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
* @returns `{ lane }` — the {@link Lane} record.
|
* @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.
|
* GET /api/lanes/:id/git — the lane's working-copy facts.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4040,7 +4100,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
removeSessionWorktree: (id: number, session: string) =>
|
removeSessionWorktree: (id: number, session: string) =>
|
||||||
request<{ removed: true; branch: string | null; branchKept: boolean }>(
|
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 }) }
|
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
|
||||||
),
|
),
|
||||||
gitBranches: (id: GitTarget) =>
|
gitBranches: (id: GitTarget) =>
|
||||||
@@ -4134,7 +4194,7 @@ export const api = {
|
|||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
* @returns {@link LaneRuntime}
|
* @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.
|
* 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
|
* Returns 202: the boot runs in the background and streams progress as
|
||||||
@@ -4143,7 +4203,7 @@ export const api = {
|
|||||||
* @param body `build: false` reuses an existing build.
|
* @param body `build: false` reuses an existing build.
|
||||||
*/
|
*/
|
||||||
up: (id: number, body: { build?: boolean } = {}) =>
|
up: (id: number, body: { build?: boolean } = {}) =>
|
||||||
request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, {
|
request<{ ok: true; laneId: number }>(`/workspaces/${id}/up`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -4154,7 +4214,7 @@ export const api = {
|
|||||||
* @returns The pids stopped and the resulting runtime facts.
|
* @returns The pids stopped and the resulting runtime facts.
|
||||||
*/
|
*/
|
||||||
down: (id: number) =>
|
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",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
}),
|
}),
|
||||||
@@ -4164,7 +4224,7 @@ export const api = {
|
|||||||
* @returns `{ lane }` — the created {@link Lane}.
|
* @returns `{ lane }` — the created {@link Lane}.
|
||||||
*/
|
*/
|
||||||
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
|
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.
|
* PATCH /api/lanes/:id — partial update of a lane.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4173,10 +4233,17 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
||||||
* folders and context files, and whether its tool can take them. */
|
* 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. */
|
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
|
||||||
setInstructions: (id: number, instructions: string | null) =>
|
setInstructions: (id: number, instructions: string | null) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
|
request<LaneSourcesView>(`/workspaces/${id}/instructions`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ instructions }),
|
body: JSON.stringify({ instructions }),
|
||||||
}),
|
}),
|
||||||
@@ -4194,19 +4261,19 @@ export const api = {
|
|||||||
progressToken?: string;
|
progressToken?: string;
|
||||||
}
|
}
|
||||||
) =>
|
) =>
|
||||||
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
||||||
addFolder: (id: number, path: string) =>
|
addFolder: (id: number, path: string) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
|
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
}),
|
}),
|
||||||
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
|
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
|
||||||
addFile: (id: number, file: File) =>
|
addFile: (id: number, file: File) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
|
request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -4217,11 +4284,11 @@ export const api = {
|
|||||||
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
||||||
* uploaded file's AgentHub copy is deleted. */
|
* uploaded file's AgentHub copy is deleted. */
|
||||||
removeSource: (id: number, sourceId: number) =>
|
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. */
|
/** 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>) =>
|
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.
|
* POST /api/lanes/:id/stage — advance the pipeline stage.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4232,14 +4299,14 @@ export const api = {
|
|||||||
id: number,
|
id: number,
|
||||||
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
|
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
|
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
|
||||||
* action-specific facts that a destructive request must echo as `expect`.
|
* action-specific facts that a destructive request must echo as `expect`.
|
||||||
*/
|
*/
|
||||||
preflight: (id: number, action: "reset" | "remove" | "purge") =>
|
preflight: (id: number, action: "reset" | "remove" | "purge") =>
|
||||||
request<import("./types").LanePreflight>(
|
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.).
|
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
|
||||||
@@ -4251,7 +4318,7 @@ export const api = {
|
|||||||
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
||||||
// `run_id` answers a `start`: the session it opened, which for a split
|
// `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`.
|
// 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",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -4262,7 +4329,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
features: {
|
features: {
|
||||||
list: (id: number) =>
|
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.
|
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4271,7 +4338,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
show: (id: number, slug: string) =>
|
show: (id: number, slug: string) =>
|
||||||
request<{ feature: import("./types").LaneFeature }>(
|
request<{ feature: import("./types").LaneFeature }>(
|
||||||
`/lanes/${id}/features/${encodeURIComponent(slug)}`
|
`/workspaces/${id}/features/${encodeURIComponent(slug)}`
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -4281,7 +4348,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
proof: {
|
proof: {
|
||||||
list: (id: number) =>
|
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>.
|
* URL for a proof image, ready to use in an <img src>.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4291,7 +4358,7 @@ export const api = {
|
|||||||
* @returns A URL string pointing to the image.
|
* @returns A URL string pointing to the image.
|
||||||
*/
|
*/
|
||||||
imageUrl: (id: number, slug: string, group: string, file: string): string =>
|
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.
|
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4300,19 +4367,19 @@ export const api = {
|
|||||||
* @returns `{ deleted }` — count of deleted images.
|
* @returns `{ deleted }` — count of deleted images.
|
||||||
*/
|
*/
|
||||||
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
|
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",
|
method: "DELETE",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
mcpSync: (id: number) =>
|
mcpSync: (id: number) =>
|
||||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
|
request<{ servers: string[]; profilesSeeded: string[] }>(`/workspaces/${id}/mcp/sync`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: "{}",
|
body: "{}",
|
||||||
}),
|
}),
|
||||||
profileInit: (id: number, force = false) =>
|
profileInit: (id: number, force = false) =>
|
||||||
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
|
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
|
||||||
`/lanes/${id}/profile/init`,
|
`/workspaces/${id}/profile/init`,
|
||||||
{ method: "POST", body: JSON.stringify({ force }) }
|
{ method: "POST", body: JSON.stringify({ force }) }
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -4408,6 +4475,9 @@ export interface Artifact {
|
|||||||
inLibrary: boolean;
|
inLibrary: boolean;
|
||||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||||
installed?: boolean;
|
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. */
|
/** file: captured when its session stopped, not pinned by hand. */
|
||||||
auto?: boolean;
|
auto?: boolean;
|
||||||
/** Server mode: nobody owns it yet. */
|
/** Server mode: nobody owns it yet. */
|
||||||
@@ -4570,6 +4640,13 @@ export interface CcProfileUsage {
|
|||||||
* user + project scope in one response. */
|
* user + project scope in one response. */
|
||||||
export type CcScope = "user" | "project" | "all";
|
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
|
/** 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
|
* var Claude Code reads; a proxy that names its models once fills them all
|
||||||
* from the profile's `model`. */
|
* from the profile's `model`. */
|
||||||
@@ -4765,6 +4842,38 @@ export interface AiProxy {
|
|||||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
* 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
|
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||||
* own words — truncated server-side, never the key. */
|
* 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 {
|
export interface AiProxyProbe {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status: number | null;
|
status: number | null;
|
||||||
@@ -5150,8 +5259,9 @@ export interface CcOverview {
|
|||||||
/** Key filesystem locations the explorer reads from. */
|
/** Key filesystem locations the explorer reads from. */
|
||||||
roots: {
|
roots: {
|
||||||
claudeHome: string;
|
claudeHome: string;
|
||||||
projectClaudeDir: string;
|
/** Null for a member's own config with no workspace named: no project layer. */
|
||||||
projectRoot: string;
|
projectClaudeDir: string | null;
|
||||||
|
projectRoot: string | null;
|
||||||
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
|
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
|
||||||
claudeJson: string;
|
claudeJson: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,13 +43,40 @@ export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
|
|||||||
"image",
|
"image",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** An edit worth a card in the conversation: a page, a document, a diagram, a
|
/** Folders an app keeps its own pages in: an HTML file under one of them is
|
||||||
* table — and a React component the model wrote whole (a `.tsx` it merely
|
* the project's code, never a page the model made to show. */
|
||||||
* edited is project code, not an artifact). */
|
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 {
|
export function isArtifact(path: string, created: boolean): boolean {
|
||||||
const k = artifactKind(path);
|
const k = artifactKind(path);
|
||||||
if (k === "react") return created;
|
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. */
|
/** A code fence language for the Code tab. */
|
||||||
|
|||||||
@@ -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
|
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||||
* a per-viewer preference, and nothing
|
* 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
|
* 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
|
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||||
* must.
|
* 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
|
* dashboard's own machine attaches by path instead of uploading
|
||||||
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
|
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
|
||||||
* file-manager copy carries —, checked by the server in `sameFilesHere` —
|
* file-manager copy carries —, checked by the server in `sameFilesHere` —
|
||||||
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
|
* `POST /api/run/local-paths` —, `localAttachment`), and the pasted or
|
||||||
* sent with the prompt; that check is the one call here to the server.
|
* 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>
|
* @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
|
/** 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,
|
* 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`. */
|
* 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. */
|
/** A file on this machine, by its absolute path, as an attachment. */
|
||||||
export function localAttachment(path: string): 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 {
|
return {
|
||||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||||
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
|
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
|
||||||
name:
|
name:
|
||||||
path
|
path
|
||||||
.replace(/[\\/]$/, "")
|
.replace(/[\\/]$/, "")
|
||||||
|
|||||||
@@ -375,6 +375,12 @@ export const SETTINGS_INDEX: {
|
|||||||
},
|
},
|
||||||
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
|
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
|
||||||
{ section: "git", titleKey: "worktrees.agenthub", 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 {
|
function tier(hay: string | undefined, q: string, exact: number): number {
|
||||||
|
|||||||
+13
-6
@@ -732,10 +732,9 @@ export interface Session {
|
|||||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||||
* journals and hooks), `"antigravity"` (agy's conversation files and
|
* journals and hooks), `"antigravity"` (agy's conversation files and
|
||||||
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
|
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens) or `"omp"`
|
||||||
* `"opencode"` (their own session stores, with tokens and subagents) or
|
* (its own session journals, with tokens and subagents). Rows an older build
|
||||||
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
|
* wrote may still say `"opencode"` or `"grok"`. Maps to `sessions.tool`. */
|
||||||
* `sessions.tool`. */
|
|
||||||
tool?: string;
|
tool?: string;
|
||||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||||
* it; null for a session no workspace started.
|
* it; null for a session no workspace started.
|
||||||
@@ -1217,7 +1216,7 @@ export interface CostResult {
|
|||||||
|
|
||||||
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
|
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
|
||||||
export interface ToolQuota {
|
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.
|
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
|
||||||
* `group` names the model pool a window belongs to (Antigravity's). */
|
* `group` names the model pool a window belongs to (Antigravity's). */
|
||||||
windows: Array<{
|
windows: Array<{
|
||||||
@@ -2634,7 +2633,7 @@ export interface LaneNode {
|
|||||||
icon: string;
|
icon: string;
|
||||||
gate: boolean;
|
gate: boolean;
|
||||||
/** The one command that drives this node — a slash command
|
/** 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
|
* from the pipeline template. Empty string for a node with no single driver
|
||||||
* (`done`, or a gate another agent declares). Display text only; the
|
* (`done`, or a gate another agent declares). Display text only; the
|
||||||
* dashboard never runs it. Optional in the type only because the archived
|
* dashboard never runs it. Optional in the type only because the archived
|
||||||
@@ -3236,6 +3235,10 @@ export interface TranscriptContent {
|
|||||||
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||||
* removed (from Claude Code's `structuredPatch`). */
|
* removed (from Claude Code's `structuredPatch`). */
|
||||||
diff?: { added: number; removed: number };
|
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
|
/** Present on an AskUserQuestion "tool_result": question text → the option
|
||||||
* the user picked, or what they typed. */
|
* the user picked, or what they typed. */
|
||||||
answers?: Record<string, string>;
|
answers?: Record<string, string>;
|
||||||
@@ -3320,6 +3323,10 @@ export interface TranscriptResult {
|
|||||||
last_line: number;
|
last_line: number;
|
||||||
/** JSONL line number of the first message in this page. */
|
/** JSONL line number of the first message in this page. */
|
||||||
first_line: number;
|
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
|
/** 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 i18n from "../i18n";
|
||||||
import { applyStoredTheme } from "../hooks/useTheme";
|
import { applyStoredTheme } from "../hooks/useTheme";
|
||||||
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
|
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). */
|
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||||
export const PREF_KEYS = [
|
export const PREF_KEYS = [
|
||||||
@@ -44,6 +45,8 @@ export const PREF_KEYS = [
|
|||||||
"agenthub.tour",
|
"agenthub.tour",
|
||||||
"agenthub.workspace.allTabs",
|
"agenthub.workspace.allTabs",
|
||||||
"agenthub.markdown.view",
|
"agenthub.markdown.view",
|
||||||
|
"agenthub.workspace.groups",
|
||||||
|
"agenthub.changes.innerRepos",
|
||||||
] as const;
|
] as const;
|
||||||
const SYNCED = new Set<string>(PREF_KEYS);
|
const SYNCED = new Set<string>(PREF_KEYS);
|
||||||
const PUSH_DELAY_MS = 400;
|
const PUSH_DELAY_MS = 400;
|
||||||
@@ -116,6 +119,8 @@ function reapply(prefs: Record<string, string>) {
|
|||||||
window.dispatchEvent(new Event("sagi:prefs"));
|
window.dispatchEvent(new Event("sagi:prefs"));
|
||||||
applyUiFontSize();
|
applyUiFontSize();
|
||||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
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;
|
const lng = prefs.i18nextLng;
|
||||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
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
|
* @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`
|
* 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.
|
* deep link, and mounts {@link App} into `#root` once the active locale is loaded.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
* — 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.
|
* 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.
|
* 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
|
* **Test** asks the endpoint itself, once as Claude Code would
|
||||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
* (`/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 { copyText } from "../lib/clipboard";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import { useAuth } from "../components/AuthGate";
|
import { useAuth } from "../components/AuthGate";
|
||||||
|
import { NineRouterUpdate } from "../components/settings/NineRouterUpdate";
|
||||||
|
|
||||||
const NINE_ROUTER_PORT = 20128;
|
const NINE_ROUTER_PORT = 20128;
|
||||||
|
|
||||||
@@ -271,6 +274,7 @@ export function AiProxyConfig() {
|
|||||||
{t("aiProxy.config9router")}
|
{t("aiProxy.config9router")}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{nineRouter && isAdmin && <NineRouterUpdate />}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startAdd}
|
onClick={startAdd}
|
||||||
|
|||||||
+780
-308
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,11 @@
|
|||||||
* files, and the `MdItemList` artifacts) also opens a
|
* files, and the `MdItemList` artifacts) also opens a
|
||||||
* right-click menu that repeats those buttons — same handlers, same
|
* right-click menu that repeats those buttons — same handlers, same
|
||||||
* confirm path — plus Copy name/path.
|
* confirm path — plus Copy name/path.
|
||||||
|
* Server mode, a non-admin with their own Linux account (S3): the page is
|
||||||
|
* THEIR config (`useCcTarget`, labelled as such, no project layer unless a
|
||||||
|
* workspace is named), and the host-wide controls — Switch profile, Update
|
||||||
|
* all, AgentHub's own Claude settings — are an admin's and hidden (the server
|
||||||
|
* refuses them anyway).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -126,6 +131,8 @@ import { ScopeBadge } from "../components/config/ScopeBadge";
|
|||||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||||
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
|
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
|
import { useCcTarget } from "../hooks/useCcTarget";
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
SkeletonRows,
|
SkeletonRows,
|
||||||
@@ -516,8 +523,14 @@ export function CcConfig() {
|
|||||||
|
|
||||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||||||
const projectRoot = data.overview?.roots.projectRoot ?? null;
|
const projectRoot = data.overview?.roots.projectRoot ?? null;
|
||||||
// Project files under the plugin cache are wiped by the next plugin update.
|
// Server mode: whose config this is. The host-wide controls are an admin's.
|
||||||
const projectDisabled = isPluginCachePath(projectRoot);
|
const { isAdmin } = usePermissions();
|
||||||
|
const ccTarget = useCcTarget();
|
||||||
|
const ownHome = ccTarget?.kind === "own" ? ccTarget.claudeHome : null;
|
||||||
|
// Project files under the plugin cache are wiped by the next plugin update;
|
||||||
|
// a member's own config has no project layer at all.
|
||||||
|
const projectDisabled =
|
||||||
|
isPluginCachePath(projectRoot) || (!!data.overview && projectRoot === null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectDisabled && scope === "project") setScope("all");
|
if (projectDisabled && scope === "project") setScope("all");
|
||||||
}, [projectDisabled, scope]);
|
}, [projectDisabled, scope]);
|
||||||
@@ -681,6 +694,8 @@ export function CcConfig() {
|
|||||||
}}
|
}}
|
||||||
onOpenBackups={() => setBackupsOpen(true)}
|
onOpenBackups={() => setBackupsOpen(true)}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
|
showProfiles={isAdmin}
|
||||||
|
ownHome={ownHome}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
|
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
|
||||||
@@ -777,7 +792,7 @@ export function CcConfig() {
|
|||||||
|
|
||||||
{/* AgentHub's own Claude Code settings follow the overview, outside the
|
{/* AgentHub's own Claude Code settings follow the overview, outside the
|
||||||
list's search box and scope. */}
|
list's search box and scope. */}
|
||||||
{tab === "overview" && (
|
{tab === "overview" && isAdmin && (
|
||||||
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
|
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -820,10 +835,22 @@ interface HeaderProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onOpenBackups: () => void;
|
onOpenBackups: () => void;
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
|
/** The host's login profiles link: an admin's (and local mode's). */
|
||||||
|
showProfiles?: boolean;
|
||||||
|
/** A member's own Claude home (server mode, S3): the page says whose it is. */
|
||||||
|
ownHome?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
|
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
|
||||||
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
|
function Header({
|
||||||
|
loading,
|
||||||
|
lastUpdated,
|
||||||
|
onRefresh,
|
||||||
|
onOpenBackups,
|
||||||
|
wsConnected,
|
||||||
|
showProfiles = true,
|
||||||
|
ownHome = null,
|
||||||
|
}: HeaderProps) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
const settingsHref = useSettingsHref();
|
const settingsHref = useSettingsHref();
|
||||||
const { t: tCommon } = useTranslation("common");
|
const { t: tCommon } = useTranslation("common");
|
||||||
@@ -848,18 +875,30 @@ function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }:
|
|||||||
{tCommon("offline")}
|
{tCommon("offline")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
|
{ownHome ? (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium text-fg-primary" data-testid="cc-own-config">
|
||||||
|
{t("own.label", { home: ownHome })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-fg-muted max-w-2xl">{t("own.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 justify-end flex-wrap">
|
<div className="flex items-center gap-2 justify-end flex-wrap">
|
||||||
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
|
{/* Every AI tool's login profiles live in one place: Settings → AI tools.
|
||||||
<Link
|
The host's logins: an admin's. */}
|
||||||
to={settingsHref("agents#tool-profiles")}
|
{showProfiles && (
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
<Link
|
||||||
>
|
to={settingsHref("agents#tool-profiles")}
|
||||||
<UserCog className="w-3.5 h-3.5" />
|
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
{t("profiles.openButton")}
|
>
|
||||||
</Link>
|
<UserCog className="w-3.5 h-3.5" />
|
||||||
|
{t("profiles.openButton")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenBackups}
|
onClick={onOpenBackups}
|
||||||
@@ -1290,18 +1329,31 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
|
|||||||
{t("overview.rootsTitle")}
|
{t("overview.rootsTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<RootRow
|
{/* A member's own config has no project layer: their Claude home instead. */}
|
||||||
icon={FolderTree}
|
{roots.projectRoot === null && (
|
||||||
tone="emerald"
|
<RootRow
|
||||||
label={t("overview.projectClaudeDir")}
|
icon={FolderTree}
|
||||||
value={roots.projectClaudeDir}
|
tone="emerald"
|
||||||
/>
|
label={t("overview.claudeHome")}
|
||||||
<RootRow
|
value={roots.claudeHome}
|
||||||
icon={FolderTree}
|
/>
|
||||||
tone="violet"
|
)}
|
||||||
label={t("overview.projectRoot")}
|
{roots.projectClaudeDir !== null && (
|
||||||
value={roots.projectRoot}
|
<RootRow
|
||||||
/>
|
icon={FolderTree}
|
||||||
|
tone="emerald"
|
||||||
|
label={t("overview.projectClaudeDir")}
|
||||||
|
value={roots.projectClaudeDir}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{roots.projectRoot !== null && (
|
||||||
|
<RootRow
|
||||||
|
icon={FolderTree}
|
||||||
|
tone="violet"
|
||||||
|
label={t("overview.projectRoot")}
|
||||||
|
value={roots.projectRoot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<RootRow
|
<RootRow
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
tone="amber"
|
tone="amber"
|
||||||
@@ -1718,6 +1770,7 @@ function PluginsPanel({
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const { busy, run } = useConfigAction(onToast, onChanged);
|
const { busy, run } = useConfigAction(onToast, onChanged);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
|
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
|
||||||
@@ -1785,7 +1838,8 @@ function PluginsPanel({
|
|||||||
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
|
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UpdateAllCard onToast={onToast} onChanged={onChanged} />
|
{/* Update all runs on the host's plugins (AgentHub's own included): an admin's. */}
|
||||||
|
{isAdmin && <UpdateAllCard onToast={onToast} onChanged={onChanged} />}
|
||||||
|
|
||||||
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
|
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
|
||||||
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
|
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
|
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
|
||||||
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
|
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
|
||||||
* while the tab is hidden behind a session.
|
* while the tab is hidden behind a session.
|
||||||
|
* Server mode hides every tool page from a non-admin, except Claude Code for a
|
||||||
|
* non-admin with their own Linux account (`useCcTarget`): "Your Claude Code
|
||||||
|
* config", which they manage as themselves.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -73,7 +76,9 @@ import { ToolProfilesSettings } from "../components/settings/ToolProfilesSetting
|
|||||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||||
|
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
|
import { useCcTarget } from "../hooks/useCcTarget";
|
||||||
import {
|
import {
|
||||||
rankSettings,
|
rankSettings,
|
||||||
rankSettingsEntries,
|
rankSettingsEntries,
|
||||||
@@ -247,10 +252,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
};
|
};
|
||||||
}, [location.key, location.hash]);
|
}, [location.key, location.hash]);
|
||||||
|
|
||||||
const { isAdmin, local } = usePermissions();
|
const { isAdmin, local, seesAll } = usePermissions();
|
||||||
const authState = useAuth().state;
|
const authState = useAuth().state;
|
||||||
const signedIn = authState.mode === "server" && !!authState.user;
|
const signedIn = authState.mode === "server" && !!authState.user;
|
||||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||||
|
// Server mode: a non-admin with their own Linux account (S3) manages their
|
||||||
|
// OWN Claude Code config here; the host's stays an admin's.
|
||||||
|
const ccTarget = useCcTarget();
|
||||||
|
const ownClaude = !isAdmin && ccTarget?.kind === "own";
|
||||||
const s = (key: string) => ({
|
const s = (key: string) => ({
|
||||||
title: t(`settingsNav.section.${key}.title`),
|
title: t(`settingsNav.section.${key}.title`),
|
||||||
description: t(`settingsNav.section.${key}.description`),
|
description: t(`settingsNav.section.${key}.description`),
|
||||||
@@ -298,8 +307,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
<AgentInstructionsSettings />
|
<AgentInstructionsSettings />
|
||||||
<SessionDefaultsSettings />
|
<SessionDefaultsSettings />
|
||||||
<AiToolsSettings settingsHref={toolHref} />
|
<AiToolsSettings settingsHref={toolHref} />
|
||||||
{/* Every AI tool's login profiles, linked to the account pool. */}
|
{/* Every AI tool's login profiles, linked to the account pool: the
|
||||||
<ToolProfilesSettings />
|
host's logins, a staff-only read (a developer would get 403s). */}
|
||||||
|
{seesAll && <ToolProfilesSettings />}
|
||||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||||
<ArtifactsHubSettings />
|
<ArtifactsHubSettings />
|
||||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||||
@@ -314,7 +324,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
group: "agents" as const,
|
group: "agents" as const,
|
||||||
tool: "claude",
|
tool: "claude",
|
||||||
Icon: toolNavIcon("claude", "Claude Code"),
|
Icon: toolNavIcon("claude", "Claude Code"),
|
||||||
...s("claudeCode"),
|
...s(ownClaude ? "claudeCodeOwn" : "claudeCode"),
|
||||||
render: () => <CcConfig />,
|
render: () => <CcConfig />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -424,6 +434,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
<GitIdentitySettings />
|
<GitIdentitySettings />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
<div id="changes-panel" className="scroll-mt-24">
|
||||||
|
<ChangesPanelSettings />
|
||||||
|
</div>
|
||||||
<div id="worktrees" className="scroll-mt-24">
|
<div id="worktrees" className="scroll-mt-24">
|
||||||
<WorktreeVisibilitySettings />
|
<WorktreeVisibilitySettings />
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +448,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
|
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
|
||||||
const sections = isAdmin
|
const sections = isAdmin
|
||||||
? allSections
|
? allSections
|
||||||
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
|
: allSections.filter(
|
||||||
|
(x) =>
|
||||||
|
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
|
||||||
|
);
|
||||||
const navSections = sections.filter((x) => !x.tool);
|
const navSections = sections.filter((x) => !x.tool);
|
||||||
const toolTabs = sections.filter((x) => x.tool);
|
const toolTabs = sections.filter((x) => x.tool);
|
||||||
function toolHref(id: string) {
|
function toolHref(id: string) {
|
||||||
|
|||||||
+113
-19
@@ -69,8 +69,10 @@ import {
|
|||||||
ChartColumn,
|
ChartColumn,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
GitBranch,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api, type GitSource } from "../lib/api";
|
||||||
|
import { useInnerReposMode } from "../lib/innerReposPref";
|
||||||
import { usePermissions } from "../components/AuthGate";
|
import { usePermissions } from "../components/AuthGate";
|
||||||
import type {
|
import type {
|
||||||
CwdSuggestion,
|
CwdSuggestion,
|
||||||
@@ -940,6 +942,43 @@ export function Workspace() {
|
|||||||
);
|
);
|
||||||
}, [panelLaneId, takeGitFolders]);
|
}, [panelLaneId, takeGitFolders]);
|
||||||
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||||||
|
// The repos INSIDE the workspace folder (a parent folder of several repos):
|
||||||
|
// one section each, read with `?repo=` (`GET /lanes/:id/repos`, re-read
|
||||||
|
// with the panel's own refresh and every minute, so a clone shows up) —
|
||||||
|
// unless the account chose the folder's own git only (Settings → Git →
|
||||||
|
// Changes panel), which neither walks the folder nor shows them.
|
||||||
|
const innerMode = useInnerReposMode();
|
||||||
|
const [innerRepos, setInnerRepos] = useState<{
|
||||||
|
laneId: number;
|
||||||
|
root: boolean;
|
||||||
|
items: { path: string; name: string }[];
|
||||||
|
} | null>(null);
|
||||||
|
const loadInnerRepos = useCallback((laneId: number, refresh = false) => {
|
||||||
|
api.lanes.repos(laneId, refresh).then(
|
||||||
|
(v) =>
|
||||||
|
setInnerRepos((prev) =>
|
||||||
|
prev?.laneId === laneId &&
|
||||||
|
prev.root === v.root &&
|
||||||
|
prev.items.map((r) => r.path).join("\n") === v.items.map((r) => r.path).join("\n")
|
||||||
|
? prev
|
||||||
|
: { laneId, root: v.root, items: v.items }
|
||||||
|
),
|
||||||
|
() => setInnerRepos({ laneId, root: true, items: [] })
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (panelLaneId === null || innerMode === "own") return;
|
||||||
|
loadInnerRepos(panelLaneId);
|
||||||
|
const timer = setInterval(() => loadInnerRepos(panelLaneId, true), 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [panelLaneId, loadInnerRepos, innerMode]);
|
||||||
|
const inner = innerMode === "all" && innerRepos?.laneId === panelLaneId ? innerRepos : null;
|
||||||
|
const repos = inner?.items ?? [];
|
||||||
|
// A folder that is no repo itself but holds some: its own section would only
|
||||||
|
// say "not a git repository", so the repos stand alone.
|
||||||
|
const showRoot = !inner || inner.root || repos.length === 0;
|
||||||
|
// A file the chat asks for lies in the inner repo whose path prefixes it.
|
||||||
|
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
|
||||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||||
@@ -990,15 +1029,19 @@ export function Workspace() {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
const panelLaneRef = useRef<number | null>(null);
|
const panelLaneRef = useRef<number | null>(null);
|
||||||
|
const repoOfRef = useRef(repoOf);
|
||||||
|
repoOfRef.current = repoOf;
|
||||||
panelLaneRef.current = panelAt?.laneId ?? null;
|
panelLaneRef.current = panelAt?.laneId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOpen = (e: Event) => {
|
const onOpen = (e: Event) => {
|
||||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||||
if (laneId !== panelLaneRef.current) return;
|
if (laneId !== panelLaneRef.current) return;
|
||||||
// Its path is the workspace folder's: unfold that section.
|
// Its path is the workspace folder's: unfold that section (or the
|
||||||
|
// inner repo's that holds it).
|
||||||
|
const repo = repoOfRef.current(path);
|
||||||
setFoldedSources((prev) => {
|
setFoldedSources((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete("");
|
next.delete(repo ? `repo:${repo.path}` : "");
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setChangesOpen(true);
|
setChangesOpen(true);
|
||||||
@@ -1208,7 +1251,7 @@ export function Workspace() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
// Mirrors `agenthub workspace pipeline <template> <id>`: same PATCH, same
|
||||||
// stage-mismatch warning when the current declared stage matches no node
|
// stage-mismatch warning when the current declared stage matches no node
|
||||||
// in the newly chosen template.
|
// in the newly chosen template.
|
||||||
const handlePipelineChange = async (id: number, pipeline: string) => {
|
const handlePipelineChange = async (id: number, pipeline: string) => {
|
||||||
@@ -1377,7 +1420,7 @@ export function Workspace() {
|
|||||||
<div data-testid="proof-gallery" className="mt-2">
|
<div data-testid="proof-gallery" className="mt-2">
|
||||||
{proofFeature.ticket_report && (
|
{proofFeature.ticket_report && (
|
||||||
<a
|
<a
|
||||||
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-xs text-fg-muted"
|
className="text-xs text-fg-muted"
|
||||||
@@ -1612,9 +1655,43 @@ export function Workspace() {
|
|||||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||||
{panelAt &&
|
{panelAt &&
|
||||||
[
|
[
|
||||||
{ key: "", source: undefined as number | undefined },
|
...(showRoot
|
||||||
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
? [
|
||||||
].map(({ key, source }, i) => {
|
{
|
||||||
|
key: "",
|
||||||
|
source: undefined as GitSource | undefined,
|
||||||
|
name:
|
||||||
|
currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() ||
|
||||||
|
currentLane.cwd,
|
||||||
|
root: undefined as string | undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...repos.map((r) => ({
|
||||||
|
key: `repo:${r.path}`,
|
||||||
|
source: r.path as GitSource,
|
||||||
|
name: r.name,
|
||||||
|
root: `${currentLane.cwd.replace(/[\\/]+$/, "")}/${r.path}`,
|
||||||
|
})),
|
||||||
|
...folders.map((f) => ({
|
||||||
|
key: String(f.id),
|
||||||
|
source: f.id as GitSource,
|
||||||
|
name: f.name,
|
||||||
|
root: f.path,
|
||||||
|
})),
|
||||||
|
].map(({ key, source, name, root: dragRoot }, _i, sections) => {
|
||||||
|
// The chat's file request goes to the section holding it.
|
||||||
|
const req =
|
||||||
|
source === undefined
|
||||||
|
? changesRequest
|
||||||
|
: typeof source === "string" && changesRequest
|
||||||
|
? changesRequest.path.startsWith(`${source}/`)
|
||||||
|
? {
|
||||||
|
path: changesRequest.path.slice(source.length + 1),
|
||||||
|
nonce: changesRequest.nonce,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
const panel = (
|
const panel = (
|
||||||
<LaneChangesPanel
|
<LaneChangesPanel
|
||||||
// A new tree is a new panel: no read for the previous
|
// A new tree is a new panel: no read for the previous
|
||||||
@@ -1627,8 +1704,8 @@ export function Workspace() {
|
|||||||
full={changesFull}
|
full={changesFull}
|
||||||
wide={changesRatio(changesSize) >= 0.45}
|
wide={changesRatio(changesSize) >= 0.45}
|
||||||
onDirtyChange={dirtyFor(key)}
|
onDirtyChange={dirtyFor(key)}
|
||||||
openRequest={source ? null : changesRequest}
|
openRequest={req}
|
||||||
filesChanged={source ? 0 : filesChanged}
|
filesChanged={source === undefined ? filesChanged : 0}
|
||||||
// A sibling worktree forks from the lane's SOURCE repo
|
// A sibling worktree forks from the lane's SOURCE repo
|
||||||
// when it has one (this lane is itself a worktree of
|
// when it has one (this lane is itself a worktree of
|
||||||
// it); an adopted lane is its own repo.
|
// it); an adopted lane is its own repo.
|
||||||
@@ -1641,23 +1718,20 @@ export function Workspace() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
// No extra folders: the one panel, as it always was.
|
// One repo only: the one panel, as it always was.
|
||||||
if (!folders.length) return panel;
|
if (sections.length === 1) return panel;
|
||||||
// Else every repo is a section, stacked like VS Code's
|
// Else every repo is a section, stacked like VS Code's
|
||||||
// Source Control; the open ones share the height. A
|
// Source Control; the open ones share the height. A
|
||||||
// folded one stays mounted (CSS), so an unsaved edit in
|
// folded one stays mounted (CSS), so an unsaved edit in
|
||||||
// it survives.
|
// it survives.
|
||||||
const folded = foldedSources.has(key);
|
const folded = foldedSources.has(key);
|
||||||
const name = source
|
|
||||||
? folders[i - 1]!.name
|
|
||||||
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
key={key}
|
key={key}
|
||||||
data-testid="panel-git-source"
|
data-testid="panel-git-source"
|
||||||
// A row dragged from an extra folder lies outside
|
// A row dragged from an extra folder lies outside
|
||||||
// the pane's folder: `dragProjectPath` makes it absolute.
|
// the pane's folder: `dragProjectPath` makes it absolute.
|
||||||
data-drag-root={source ? folders[i - 1]!.path : undefined}
|
data-drag-root={dragRoot}
|
||||||
className={`flex flex-col border-b border-border-light ${
|
className={`flex flex-col border-b border-border-light ${
|
||||||
folded ? "shrink-0" : "min-h-0 flex-1"
|
folded ? "shrink-0" : "min-h-0 flex-1"
|
||||||
}`}
|
}`}
|
||||||
@@ -1680,7 +1754,12 @@ export function Workspace() {
|
|||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{name}</span>
|
{typeof source === "string" && (
|
||||||
|
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||||
|
)}
|
||||||
|
<span className="truncate" title={dragRoot}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
||||||
{panel}
|
{panel}
|
||||||
@@ -1695,7 +1774,18 @@ export function Workspace() {
|
|||||||
onClose={sourceCtx.close}
|
onClose={sourceCtx.close}
|
||||||
testId="panel-git-source-menu"
|
testId="panel-git-source-menu"
|
||||||
>
|
>
|
||||||
{sourceCtx.menu.item !== "" && (
|
{innerMode === "all" && (
|
||||||
|
<MenuItem
|
||||||
|
testId="panel-git-source-rescan"
|
||||||
|
onSelect={() => {
|
||||||
|
sourceCtx.close();
|
||||||
|
loadInnerRepos(panelAt.laneId, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tLanes("changes.rescanRepos")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{/^\d+$/.test(sourceCtx.menu.item) && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
testId="panel-git-source-close"
|
testId="panel-git-source-close"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -1777,7 +1867,11 @@ export function Workspace() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{panelAt &&
|
{panelAt &&
|
||||||
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
[
|
||||||
|
...(showRoot ? [undefined] : []),
|
||||||
|
...repos.map((r): GitSource => r.path),
|
||||||
|
...folders.map((f): GitSource => f.id),
|
||||||
|
].map((source) => (
|
||||||
<LaneGitRailCounts
|
<LaneGitRailCounts
|
||||||
key={source ?? ""}
|
key={source ?? ""}
|
||||||
laneId={panelAt.laneId}
|
laneId={panelAt.laneId}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
|
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
|
||||||
* Add menu closes on Escape or a click outside it, the front page is shelved
|
* Add menu closes on Escape or a click outside it, the front page is shelved
|
||||||
* by kind until a category is picked, and a card opens its detail sheet.
|
* by kind until a category is picked, and a card opens its detail sheet.
|
||||||
|
* Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu.
|
||||||
|
* A section shows 30 entries before "Show more", and three or more skills of
|
||||||
|
* one owner sharing a name prefix are one pack row that installs them all.
|
||||||
|
* An installed skill whose owner changed it shows Update (after a confirmation).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -33,6 +37,7 @@ vi.mock("../../lib/api", () => ({
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
install: vi.fn(),
|
install: vi.fn(),
|
||||||
|
updateInstalled: vi.fn(),
|
||||||
uninstall: vi.fn(),
|
uninstall: vi.fn(),
|
||||||
installs: vi.fn(),
|
installs: vi.fn(),
|
||||||
setInstallsEnabled: vi.fn(),
|
setInstallsEnabled: vi.fn(),
|
||||||
@@ -113,6 +118,16 @@ const addMenu = async (user: ReturnType<typeof userEvent.setup>, label: string)
|
|||||||
await user.click(screen.getByRole("button", { name: label }));
|
await user.click(screen.getByRole("button", { name: label }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Run one item of the first (or the given) row's ⋯ menu. */
|
||||||
|
async function menuAction(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
name: string,
|
||||||
|
scope: HTMLElement = document.body
|
||||||
|
) {
|
||||||
|
await user.click(within(scope).getAllByRole("button", { name: "More actions" })[0]!);
|
||||||
|
await user.click(within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name }));
|
||||||
|
}
|
||||||
|
|
||||||
describe("Adding", () => {
|
describe("Adding", () => {
|
||||||
it("adds the plugins picked from a marketplace link", async () => {
|
it("adds the plugins picked from a marketplace link", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -218,13 +233,22 @@ describe("Artifacts", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
|
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
|
await user.click(screen.getAllByRole("button", { name: "More actions" })[0]!);
|
||||||
// One merged list by default; Hub is a filter over it.
|
expect(
|
||||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
|
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
// One merged list by default; Hub is a filter over it. The Suggested
|
||||||
|
// count's own `unowned` list runs whenever settings land, so it is left
|
||||||
|
// out: only the order of the LIST loads is under test.
|
||||||
|
const listScopes = () =>
|
||||||
|
vi
|
||||||
|
.mocked(api.artifacts.list)
|
||||||
|
.mock.calls.map(([opts]) => opts?.scope)
|
||||||
|
.filter((s) => s !== "unowned");
|
||||||
|
expect(listScopes().pop()).toBe("all");
|
||||||
await user.click(screen.getByRole("button", { name: "Hub" }));
|
await user.click(screen.getByRole("button", { name: "Hub" }));
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(listScopes().pop()).toBe("hub"));
|
||||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("splits the Hub and My library into yours and others'", async () => {
|
it("splits the Hub and My library into yours and others'", async () => {
|
||||||
@@ -271,20 +295,46 @@ describe("Artifacts", () => {
|
|||||||
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
|
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pages a long section 12 cards at a time", async () => {
|
it("shows a long section 30 entries at a time, then the rest on Show more", async () => {
|
||||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
items: Array.from({ length: 14 }, (_, i) =>
|
items: Array.from({ length: 34 }, (_, i) =>
|
||||||
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
|
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("p1");
|
await screen.findByText("p1");
|
||||||
expect(screen.queryByText("p13")).toBeNull();
|
expect(screen.getByText("p30")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument();
|
expect(screen.queryByText("p31")).toBeNull();
|
||||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
await user.click(screen.getByRole("button", { name: "Show 4 more" }));
|
||||||
expect(screen.getByText("p13")).toBeInTheDocument();
|
expect(screen.getByText("p34")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("p1")).toBeNull();
|
expect(screen.getByText("p1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds three skills sharing a name prefix into one pack that installs them all", async () => {
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
item({ id: 1, kind: "skill", title: "sg-qa", source: "sg-qa" }),
|
||||||
|
item({ id: 2, kind: "skill", title: "sg-ship", source: "sg-ship", installed: true }),
|
||||||
|
item({ id: 3, kind: "skill", title: "sg-fix", source: "sg-fix" }),
|
||||||
|
item({ id: 4, kind: "skill", title: "lone", source: "lone" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(api.artifacts.install).mockResolvedValue({ installed: "/h/x" } as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
const pack = await screen.findByTestId("pack-sg");
|
||||||
|
expect(within(pack).getByText("sg-* pack")).toBeInTheDocument();
|
||||||
|
expect(within(pack).getByText("3 skills")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("artifact-1")).toBeNull();
|
||||||
|
expect(screen.getByTestId("artifact-4")).toBeInTheDocument();
|
||||||
|
await user.click(within(pack).getByRole("button", { expanded: false }));
|
||||||
|
expect(within(pack).getByTestId("artifact-1")).toBeInTheDocument();
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||||
|
await user.click(within(pack).getByRole("button", { name: "Install 2 more" }));
|
||||||
|
await waitFor(() => expect(api.artifacts.install).toHaveBeenCalledTimes(2));
|
||||||
|
expect(api.artifacts.install).toHaveBeenCalledWith(1);
|
||||||
|
expect(api.artifacts.install).toHaveBeenCalledWith(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists who installed an artifact, when and where, from its install count", async () => {
|
it("lists who installed an artifact, when and where, from its install count", async () => {
|
||||||
@@ -333,12 +383,38 @@ describe("Artifacts", () => {
|
|||||||
expect(within(card).getByText("Hub only")).toBeInTheDocument();
|
expect(within(card).getByText("Hub only")).toBeInTheDocument();
|
||||||
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
||||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||||
await user.click(within(card).getByRole("button", { name: "Remove from Hub" }));
|
await menuAction(user, "Remove from Hub", card);
|
||||||
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
|
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
|
||||||
const calls = vi.mocked(confirmDialog).mock.calls;
|
const calls = vi.mocked(confirmDialog).mock.calls;
|
||||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
item({
|
||||||
|
id: 2,
|
||||||
|
kind: "skill",
|
||||||
|
title: "deploy",
|
||||||
|
source: "deploy",
|
||||||
|
installed: true,
|
||||||
|
updateAvailable: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(api.artifacts.updateInstalled).mockResolvedValue({ installed: "/h/x" });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
const card = await screen.findByTestId("artifact-2");
|
||||||
|
expect(within(card).queryByTestId("artifact-installed")).toBeNull();
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||||
|
await user.click(within(card).getByRole("button", { name: "Update" }));
|
||||||
|
expect(api.artifacts.updateInstalled).not.toHaveBeenCalled();
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||||
|
await user.click(within(card).getByRole("button", { name: "Update" }));
|
||||||
|
await waitFor(() => expect(api.artifacts.updateInstalled).toHaveBeenCalledWith(2));
|
||||||
|
});
|
||||||
|
|
||||||
it("shows Installed instead of Install for a skill already installed", async () => {
|
it("shows Installed instead of Install for a skill already installed", async () => {
|
||||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
|
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
|
||||||
@@ -436,7 +512,7 @@ describe("Artifacts", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("standup");
|
await screen.findByText("standup");
|
||||||
await user.click(screen.getByRole("button", { name: "Preview" }));
|
await menuAction(user, "Preview");
|
||||||
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
|
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
|
||||||
expect(screen.queryByTestId("artifact-preview")).toBeNull();
|
expect(screen.queryByTestId("artifact-preview")).toBeNull();
|
||||||
@@ -464,7 +540,7 @@ describe("Artifacts", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("standup");
|
await screen.findByText("standup");
|
||||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
await menuAction(user, "Edit");
|
||||||
const name = await screen.findByLabelText(/Skill name/);
|
const name = await screen.findByLabelText(/Skill name/);
|
||||||
expect(name).toHaveValue("standup");
|
expect(name).toHaveValue("standup");
|
||||||
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
|
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* @file Covers the Claude Code config page as a MEMBER's own config (server
|
||||||
|
* mode, a non-admin with their own Linux account): it says whose config it is,
|
||||||
|
* and the host-wide controls — Switch profile, Update all, AgentHub's own
|
||||||
|
* Claude settings — are not shown (the server refuses them anyway). An admin
|
||||||
|
* still sees all three.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
const perms = vi.hoisted(() => ({ isAdmin: false }));
|
||||||
|
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
||||||
|
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
usePermissions: () => ({ ...m.usePermissions(), local: false, isAdmin: perms.isAdmin }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../../components/settings/ClaudeAgentHubSettings", () => ({
|
||||||
|
ClaudeAgentHubSettings: () => <div data-testid="agenthub-claude-settings" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const HOME = "/srv/users/mia/.claude";
|
||||||
|
const ccConfigMock = vi.hoisted(() => {
|
||||||
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||||
|
return {
|
||||||
|
target: r({ kind: "own", readOnly: false, claudeHome: "/srv/users/mia/.claude" }),
|
||||||
|
overview: r({
|
||||||
|
roots: {
|
||||||
|
claudeHome: "/srv/users/mia/.claude",
|
||||||
|
projectClaudeDir: null,
|
||||||
|
projectRoot: null,
|
||||||
|
claudeJson: "/srv/users/mia/.claude.json",
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
skills: { user: 0, project: 0 },
|
||||||
|
agents: { user: 0, project: 0 },
|
||||||
|
commands: { user: 0, project: 0 },
|
||||||
|
outputStyles: { user: 0, project: 0 },
|
||||||
|
mcpServers: { user: 0, project: 0 },
|
||||||
|
hooks: {},
|
||||||
|
plugins: 0,
|
||||||
|
marketplaces: 0,
|
||||||
|
keybindings: 0,
|
||||||
|
settingsFiles: 0,
|
||||||
|
memory: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
skills: r({ items: [] }),
|
||||||
|
agents: r({ items: [] }),
|
||||||
|
commands: r({ items: [] }),
|
||||||
|
outputStyles: r({ items: [] }),
|
||||||
|
memory: r({ items: [] }),
|
||||||
|
plugins: r({ manifestPath: null, manifestExists: false, plugins: [] }),
|
||||||
|
marketplaces: r({ items: [] }),
|
||||||
|
mcp: r({ user: [], projectScoped: [] }),
|
||||||
|
hooks: r({ items: [] }),
|
||||||
|
settings: r({ items: [] }),
|
||||||
|
keybindings: r({ groups: [] }),
|
||||||
|
statusline: r({}),
|
||||||
|
hookScripts: r({ items: [] }),
|
||||||
|
backups: r({ items: [] }),
|
||||||
|
pluginsAvailable: r({ installed: [], available: [] }),
|
||||||
|
updateAllStatus: r({ batch: null }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/eventBus", () => ({
|
||||||
|
eventBus: {
|
||||||
|
subscribe: () => () => {},
|
||||||
|
publish: () => {},
|
||||||
|
onConnection: () => () => {},
|
||||||
|
connected: true,
|
||||||
|
setConnected: () => {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CcConfig } from "../CcConfig";
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CcConfig />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
perms.isAdmin = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CcConfig — a member's own config", () => {
|
||||||
|
it("says whose config it is and hides the host-wide controls", async () => {
|
||||||
|
await open();
|
||||||
|
expect(screen.getByTestId("cc-own-config")).toHaveTextContent(HOME);
|
||||||
|
expect(screen.queryByRole("link", { name: /switch profile/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("agenthub-claude-settings")).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||||
|
await settle();
|
||||||
|
expect(screen.queryByRole("button", { name: /^update all$/i })).not.toBeInTheDocument();
|
||||||
|
expect(ccConfigMock.updateAllStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps all three for an admin", async () => {
|
||||||
|
perms.isAdmin = true;
|
||||||
|
await open();
|
||||||
|
expect(screen.queryByTestId("cc-own-config")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: /switch profile/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("agenthub-claude-settings")).toBeInTheDocument();
|
||||||
|
expect(ccConfigMock.target).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,10 +21,15 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
|||||||
import type { AiTool, AiToolsView } from "../../lib/api";
|
import type { AiTool, AiToolsView } from "../../lib/api";
|
||||||
|
|
||||||
// Local mode unless a test says otherwise; still an admin either way.
|
// Local mode unless a test says otherwise; still an admin either way.
|
||||||
const perms = vi.hoisted(() => ({ local: true }));
|
const perms = vi.hoisted(() => ({ local: true, isAdmin: true }));
|
||||||
|
// What GET /api/cc-config/target answers a non-admin.
|
||||||
|
const ccTarget = vi.hoisted(() => ({ kind: "none" as "own" | "host" | "none" }));
|
||||||
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
||||||
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
||||||
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
return {
|
||||||
|
...m,
|
||||||
|
usePermissions: () => ({ ...m.usePermissions(), local: perms.local, isAdmin: perms.isAdmin }),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
vi.mock("../../components/settings/GitIdentitySettings", () => ({
|
vi.mock("../../components/settings/GitIdentitySettings", () => ({
|
||||||
GitIdentitySettings: () => <div data-testid="git-identity" />,
|
GitIdentitySettings: () => <div data-testid="git-identity" />,
|
||||||
@@ -88,7 +93,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||||
// AI tools → Profiles.
|
// AI tools → Profiles.
|
||||||
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
||||||
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
|
ccConfig: {
|
||||||
|
profiles: vi.fn(async () => ({ items: [], active: null })),
|
||||||
|
target: vi.fn(async () => ({
|
||||||
|
kind: ccTarget.kind,
|
||||||
|
readOnly: ccTarget.kind !== "own",
|
||||||
|
claudeHome: ccTarget.kind === "own" ? "/srv/users/mia/.claude" : null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -124,6 +136,8 @@ const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
perms.local = true;
|
perms.local = true;
|
||||||
|
perms.isAdmin = true;
|
||||||
|
ccTarget.kind = "none";
|
||||||
__resetAiTools();
|
__resetAiTools();
|
||||||
view = {
|
view = {
|
||||||
default: "claude",
|
default: "claude",
|
||||||
@@ -202,6 +216,29 @@ describe("SettingsHub", () => {
|
|||||||
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
|
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives a non-admin with their own account their own Claude Code page, and no other tool's", async () => {
|
||||||
|
perms.local = false;
|
||||||
|
perms.isAdmin = false;
|
||||||
|
ccTarget.kind = "own";
|
||||||
|
renderAt("/settings/agents/claude-code");
|
||||||
|
expect(await screen.findByTestId("cc-config")).toBeInTheDocument();
|
||||||
|
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
|
||||||
|
.getAllByRole("tab")
|
||||||
|
.map((a) => a.textContent);
|
||||||
|
expect(tabs).toEqual(["All tools", "Your Claude Code config"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Claude Code page from a non-admin with no account of their own", async () => {
|
||||||
|
perms.local = false;
|
||||||
|
perms.isAdmin = false;
|
||||||
|
ccTarget.kind = "none";
|
||||||
|
renderAt("/settings/agents/claude-code");
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("cc-config")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("navigates from the mobile select", async () => {
|
it("navigates from the mobile select", async () => {
|
||||||
renderAt("/settings/data");
|
renderAt("/settings/data");
|
||||||
await screen.findByTestId("settings-data");
|
await screen.findByTestId("settings-data");
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
: [],
|
: [],
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
// The repos inside the workspace folder (`?repo=`): none by default.
|
||||||
|
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
|
||||||
// Closing a folder's section removes it from the workspace.
|
// Closing a folder's section removes it from the workspace.
|
||||||
removeSource: r({
|
removeSource: r({
|
||||||
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
||||||
@@ -330,6 +332,79 @@ describe("Workspace — the Changes panel's width", () => {
|
|||||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("neither walks a parent folder nor shows its repos until the account turns it on", async () => {
|
||||||
|
vi.mocked(api.lanes.repos).mockClear();
|
||||||
|
vi.mocked(api.lanes.repos).mockResolvedValue({
|
||||||
|
root: false,
|
||||||
|
items: [{ path: "api", name: "api" }],
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
const rail = screen.queryByTestId("lane-changes-expand");
|
||||||
|
if (rail) {
|
||||||
|
fireEvent.click(rail);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
expect(api.lanes.repos).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||||
|
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives each repo inside a parent folder its own section, without the folder's own", async () => {
|
||||||
|
// Settings → Git → Changes panel: show the repositories inside.
|
||||||
|
window.localStorage.setItem("agenthub.changes.innerRepos", "all");
|
||||||
|
vi.mocked(api.lanes.repos).mockResolvedValue({
|
||||||
|
root: false,
|
||||||
|
items: [
|
||||||
|
{ path: "api", name: "api" },
|
||||||
|
{ path: "apps/web", name: "apps/web" },
|
||||||
|
],
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
vi.mocked(api.lanes.git).mockClear();
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
const rail = screen.queryByTestId("lane-changes-expand");
|
||||||
|
if (rail) {
|
||||||
|
fireEvent.click(rail);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
const sections = screen.getAllByTestId("panel-git-source");
|
||||||
|
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual([
|
||||||
|
"api",
|
||||||
|
"apps/web",
|
||||||
|
]);
|
||||||
|
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 2, repo: "apps/web" });
|
||||||
|
// An inner repo is the folder's own: its menu rescans, never closes it.
|
||||||
|
fireEvent.contextMenu(sections[1]!.querySelector("button")!);
|
||||||
|
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
|
||||||
|
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
|
||||||
|
// A chat's file inside an inner repo opens in that repo's section.
|
||||||
|
fireEvent.click(sections[1]!.querySelector("button")!);
|
||||||
|
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||||
|
act(() => {
|
||||||
|
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(
|
||||||
|
{ lane: 2, repo: "apps/web" },
|
||||||
|
"src/x.ts",
|
||||||
|
"work"
|
||||||
|
);
|
||||||
|
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("is one plain panel for a workspace with no extra folders", async () => {
|
it("is one plain panel for a workspace with no extra folders", async () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
lanes: {
|
lanes: {
|
||||||
// The Changes panel's folder picker reads the workspace's extra folders.
|
// The Changes panel's folder picker reads the workspace's extra folders.
|
||||||
sources: vi.fn().mockResolvedValue({ sources: [] }),
|
sources: vi.fn().mockResolvedValue({ sources: [] }),
|
||||||
|
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
|
||||||
list: vi.fn().mockImplementation(async () => {
|
list: vi.fn().mockImplementation(async () => {
|
||||||
recordCall("GET", "/api/lanes");
|
recordCall("GET", "/api/lanes");
|
||||||
return { lanes: lanesToReturn, counts: countsToReturn };
|
return { lanes: lanesToReturn, counts: countsToReturn };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
|
// ES2021 or newer, never Vite's es2020 default: esbuild 0.25 lowers
|
||||||
|
// xterm 6's `let r; …(r||={})` (requestMode, DECRQM) to `void 0||(i={})`
|
||||||
|
// and drops the `let`, so every DECRQM query tmux sends threw
|
||||||
|
// "i is not defined" inside the terminal's parser. React 19 needs these
|
||||||
|
// browsers anyway.
|
||||||
|
target: ["es2021", "chrome100", "edge100", "firefox100", "safari15"],
|
||||||
// Maps are still written (for debugging a build), but no bundle points at
|
// Maps are still written (for debugging a build), but no bundle points at
|
||||||
// them, so a browser never downloads them.
|
// them, so a browser never downloads them.
|
||||||
sourcemap: "hidden",
|
sourcemap: "hidden",
|
||||||
|
|||||||
+55
-12
File diff suppressed because one or more lines are too long
+2
-2
@@ -239,7 +239,7 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
|
|||||||
|
|
||||||
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
|
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
|
||||||
|
|
||||||
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. `agenthub lanes …` and `--lane <id>` are the old names and keep working — the same commands as `agenthub workspace …` and `--workspace <id>` — so scripts and skills written before the rename need no change.
|
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. The old names `agenthub lanes …` and `--lane <id>` were removed (step 4 of the rename): they exit 1 with `renamed: use agenthub workspace …` / `renamed: use --workspace <id>`. Your workspaces, sessions and history are unchanged, and the API still answers under both `/api/workspaces` and `/api/lanes`.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------- | ----------- |
|
| ------- | ----------- |
|
||||||
@@ -271,7 +271,7 @@ A workspace is a durable unit of parallel agent work — one working directory,
|
|||||||
|
|
||||||
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
|
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
|
||||||
|
|
||||||
Resolution order, shared by every cwd-resolving command: `--workspace` (old name `--lane`)/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
|
Resolution order, shared by every cwd-resolving command: `--workspace`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
|
||||||
|
|
||||||
Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack.
|
Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -513,7 +513,7 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
|
|||||||
|
|
||||||
### token_usage_hourly
|
### token_usage_hourly
|
||||||
|
|
||||||
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
|
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event — the session's own transcript plus its sub-agents' (`<session>/subagents/agent-*.jsonl`, `server/lib/subagent-usage.js`; the same sum goes into `token_usage`, so a sub-agent on the session's own model is counted too; Workflow agents stay under their own `workflow` tier) — (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
|
|||||||
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
||||||
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
|
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
|
||||||
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
|
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
|
||||||
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
|
|
||||||
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
|
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
|
||||||
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
|
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
|
||||||
|
|
||||||
|
|||||||
+5
-4
File diff suppressed because one or more lines are too long
+5
-8
@@ -310,13 +310,10 @@ per model and one agent per subagent the `task` tool spawned (its journal sits i
|
|||||||
the folder named like the session's). An omp session can be resumed by id from
|
the folder named like the session's). An omp session can be resumed by id from
|
||||||
its workspace (`omp --resume <id>`), like a Claude or Codex one.
|
its workspace (`omp --resume <id>`), like a Claude or Codex one.
|
||||||
|
|
||||||
**OpenCode** sessions are read from its SQLite store
|
Cursor sessions resume by id too (`--resume <id>`), and open as a chat like
|
||||||
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms),
|
omp ones. OpenCode and Grok Build are no longer AgentHub tools: a Grok Build
|
||||||
with their tokens and cost per model and one agent per subagent, and are
|
running the Claude hooks in `~/.claude/settings.json` is ignored, and sessions an
|
||||||
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in
|
older build recorded for either stay in the history.
|
||||||
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
|
|
||||||
hooks, recorded as Grok sessions (live status, no tokens); they resume with
|
|
||||||
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
|
|
||||||
|
|
||||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||||
@@ -377,7 +374,7 @@ both full suites.
|
|||||||
| Path | What |
|
| Path | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
| `client/` | React 19 + Vite + Tailwind dashboard |
|
||||||
| `bin/agenthub.js` | CLI |
|
| `bin/agenthub.js` | CLI |
|
||||||
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
||||||
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
||||||
|
|||||||
+1
-1
@@ -293,7 +293,7 @@ chạy cả hai bộ đầy đủ.
|
|||||||
| Đường dẫn | Là gì |
|
| Đường dẫn | Là gì |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
|
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
|
||||||
| `client/` | Dashboard React 18 + Vite + Tailwind |
|
| `client/` | Dashboard React 19 + Vite + Tailwind |
|
||||||
| `bin/agenthub.js` | CLI |
|
| `bin/agenthub.js` | CLI |
|
||||||
| `docs/` | Architecture, API, workspaces, database |
|
| `docs/` | Architecture, API, workspaces, database |
|
||||||
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
|
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
|
||||||
|
|||||||
+2
-2
@@ -194,8 +194,8 @@ graph TB
|
|||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|------------|
|
|-------|------------|
|
||||||
| **Frontend** | React 18, TypeScript 5.7, Vite 6, Tailwind CSS |
|
| **Frontend** | React 19, TypeScript 7, Vite 6, Tailwind CSS |
|
||||||
| **Backend** | Node.js 20+, Express 4.21, WebSocket |
|
| **Backend** | Node.js 22.5+, Express 5, WebSocket |
|
||||||
| **Database** | SQLite 3 (better-sqlite3 or node:sqlite) |
|
| **Database** | SQLite 3 (better-sqlite3 or node:sqlite) |
|
||||||
| **Integration** | Claude Code Hooks, MCP Server |
|
| **Integration** | Claude Code Hooks, MCP Server |
|
||||||
|
|
||||||
|
|||||||
+53
-4
@@ -65,7 +65,7 @@ The proxy has to:
|
|||||||
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
|
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
|
||||||
- set `X-Forwarded-For` and `X-Forwarded-Proto`;
|
- set `X-Forwarded-For` and `X-Forwarded-Proto`;
|
||||||
- accept request bodies as large as AgentHub's own limits (Files uploads up to
|
- accept request bodies as large as AgentHub's own limits (Files uploads up to
|
||||||
500 MB, prompt drops 20 MB). nginx's default `client_max_body_size` is 1 MB,
|
500 MB, files dropped on a terminal or a prompt box 500 MB, dropped images 20 MB). nginx's default `client_max_body_size` is 1 MB,
|
||||||
and its own 413 is an HTML page the UI can only report as a failed upload.
|
and its own 413 is an HTML page the UI can only report as a failed upload.
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -106,11 +106,13 @@ The login cookie gets `Secure` when the request came over HTTPS
|
|||||||
start (`server/db.js`), and `member` is no longer accepted.
|
start (`server/db.js`), and `member` is no longer accepted.
|
||||||
|
|
||||||
"The host's configuration" means a write (anything but GET/HEAD) under
|
"The host's configuration" means a write (anything but GET/HEAD) under
|
||||||
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`,
|
`/api/settings`, `/updates`, `/codex-config`,
|
||||||
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
||||||
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
||||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)). A
|
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)) and the
|
||||||
|
host-wide parts of `/cc-config` (see
|
||||||
|
[Your own Claude Code config](#your-own-claude-code-config)). A
|
||||||
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
|
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
|
||||||
an admin can do this". An analyst's other writes are scoped like a
|
an admin can do this". An analyst's other writes are scoped like a
|
||||||
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
||||||
@@ -120,7 +122,8 @@ exactly like a developer's.
|
|||||||
|
|
||||||
The UI hides what a role cannot use (`usePermissions` in
|
The UI hides what a role cannot use (`usePermissions` in
|
||||||
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
|
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
|
||||||
no Data, Pricing or per-tool pages, no scheduled reports or git credentials,
|
no Data, Pricing or per-tool pages (except their own Claude Code config,
|
||||||
|
for a user with their own Linux account), no scheduled reports or git credentials,
|
||||||
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
|
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
|
||||||
no install/update/enable on AI tools, no pet upload or delete, and no update
|
no install/update/enable on AI tools, no pet upload or delete, and no update
|
||||||
check; the AI proxy page shows them only their personal profiles' Edit. An
|
check; the AI proxy page shows them only their personal profiles' Edit. An
|
||||||
@@ -774,6 +777,52 @@ folder under it), and the dark/light mode picked in AgentHub becomes Claude's
|
|||||||
theme. Accepting Bypass Permissions mode stays the member's own choice, made
|
theme. Accepting Bypass Permissions mode stays the member's own choice, made
|
||||||
in their terminal.
|
in their terminal.
|
||||||
|
|
||||||
|
### Your own Claude Code config
|
||||||
|
|
||||||
|
With S3, a developer or analyst who has their own Linux account manages THEIR
|
||||||
|
OWN Claude Code configuration in Settings → AI tools → **Your Claude Code
|
||||||
|
config** — skills, subagents, commands, output styles, `CLAUDE.md` and
|
||||||
|
auto-memory, plugins, marketplaces, MCP servers, `settings.json` (hooks
|
||||||
|
included) and `keybindings.json` — the same Config Explorer an admin uses for
|
||||||
|
the host's, pointed at `<USERS_ROOT>/<name>/.claude` and
|
||||||
|
`<USERS_ROOT>/<name>/.claude.json`. How it runs:
|
||||||
|
|
||||||
|
- `/api/cc-config` gates itself (`server/lib/cc-target.js`): a non-admin
|
||||||
|
reaches only an allowlist of reads and of the writes that run as them.
|
||||||
|
`/profiles*` (the host's login switching), `/plugins/update-all` (the host's
|
||||||
|
Update all, AgentHub's own plugin included) and any endpoint not on the list
|
||||||
|
stay an admin's (`403 EADMIN`; an analyst may still READ the profiles and the
|
||||||
|
Update-all journal, as before). Every handler then resolves ONE target: the
|
||||||
|
host's Claude home for an admin (and in local mode) exactly as before, the
|
||||||
|
member's own home otherwise, and `403 ENOACCOUNT` for a non-admin with no
|
||||||
|
enabled, provisioned account — never the host's. An analyst with no account
|
||||||
|
keeps the read-only view of the host's config they had; with one, `?view=host`
|
||||||
|
gives them that view, and the file reader serves either.
|
||||||
|
- Every file write is the member's own: `write-as` (content on stdin), the
|
||||||
|
timestamped backup a `write-as copy` inside their home; their files are read
|
||||||
|
only through the guarded reader (a regular file they own, size capped), and
|
||||||
|
a directory, plugin `installPath` or marketplace `installLocation` that leads
|
||||||
|
out of their home is not followed.
|
||||||
|
- `claude plugin …`, `claude plugin marketplace …` and `claude mcp …` run as
|
||||||
|
them: `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
|
||||||
|
/opt/agenthub/tools/bin/claude …` in their home, with a timeout and the same
|
||||||
|
argument checks. User scope only, never `-y` (a plugin whose marketplace
|
||||||
|
declares an install command fails; install it from your own terminal), and
|
||||||
|
no MCP `env` values or `headers` (sudo logs its argv: add such a server with
|
||||||
|
`claude mcp add` in your own terminal). `503 ENOTSHARED` until `claude` is
|
||||||
|
in the shared tools.
|
||||||
|
- A project layer only in one of their own workspaces, named with `?cwd=`
|
||||||
|
(`403 EFOLDER` otherwise); with none named there is no project layer at all,
|
||||||
|
never the dashboard's own folder. An admin's "new CLAUDE.md in a project"
|
||||||
|
in a member's folder is written as that member too.
|
||||||
|
- A change is broadcast (`cc_config_changed`) to that member alone, and their
|
||||||
|
**Reload** (`POST /api/cc-config/reload`, or a plugin update with reload)
|
||||||
|
types `/reload-plugins` / `/reload-skills` only into the panes that run as
|
||||||
|
them. Their own config is theirs, so these writes are not audited (like
|
||||||
|
their own File Explorer writes).
|
||||||
|
|
||||||
|
Not verified on a live S3 machine yet.
|
||||||
|
|
||||||
### Plugin baseline (opt-in)
|
### Plugin baseline (opt-in)
|
||||||
|
|
||||||
Write `/etc/agenthub/plugin-baseline.json` (as
|
Write `/etc/agenthub/plugin-baseline.json` (as
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ Opus 5.5 · main · ███░░░░░░░ 34% · $1.23 · today $45.68
|
|||||||
| branch | `git symbolic-ref --short HEAD` in the session's cwd (short sha when detached; 500 ms cap) |
|
| branch | `git symbolic-ref --short HEAD` in the session's cwd (short sha when detached; 500 ms cap) |
|
||||||
| context bar | `context_window.used_percentage`, else `current_usage` (input + cache) ÷ `context_window_size`; green < 50 %, yellow < 80 %, red above |
|
| context bar | `context_window.used_percentage`, else `current_usage` (input + cache) ÷ `context_window_size`; green < 50 %, yellow < 80 %, red above |
|
||||||
| `$1.23` | this session, Claude Code's own `cost.total_cost_usd` |
|
| `$1.23` | this session, Claude Code's own `cost.total_cost_usd` |
|
||||||
| `today $…` | every session today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>` → today's `daily_costs` row |
|
| `today $…` | your sessions today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>&mine=1` → today's `daily_costs` row (in server mode only the pane's workspace owner's sessions, even for an admin; locally every session) |
|
||||||
|
|
||||||
Today's cost has a **1-second timeout** and is cached for 30 s in
|
Today's cost has a **1-second timeout** and is cached for 30 s in
|
||||||
`<os tmpdir>/agenthub-statusline-<uid>.json` — a failed fetch is cached too, so a
|
`<os tmpdir>/agenthub-statusline-<uid>.json` — a failed fetch is cached too, so a
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- [NEVER tmux kill-server](never-tmux-kill-server.md) — $TMUX beats TMUX_TMPDIR; it killed the real server + this session twice
|
- [NEVER tmux kill-server](never-tmux-kill-server.md) — $TMUX beats TMUX_TMPDIR; it killed the real server + this session twice
|
||||||
- [Analytics parity plan](analytics-parity-plan.md) — phases 2–7 shipped in v2.4.0; ../agenthub-analytics now redundant; OpenAPI for new routes still missing
|
- [Analytics parity plan](analytics-parity-plan.md) — phases 2–7 shipped in v2.4.0; ../agenthub-analytics now redundant; OpenAPI for new routes still missing
|
||||||
- [Dev/prod ports share one DB](agenthub-dev-prod-ports.md) — 4820 prod (systemd), 4830 user's dev in Warp; same dashboard.db, dev migrations hit prod
|
- [Dev/prod ports share one DB](agenthub-dev-prod-ports.md) — 4820 prod (systemd), 4830 user's dev in Warp; same dashboard.db, dev migrations hit prod
|
||||||
- [Workspace rename step 4](workspace-rename-step4.md) — remove `agenthub lanes`/`--lane` only ≥3 releases after aliases ship; never rename stored data
|
- [Workspace rename step 4](workspace-rename-step4.md) — DONE 10-26: old CLI names refused, clients call /api/workspaces, /api/lanes kept; release needs EN+VI note
|
||||||
- [No git stash here](no-git-stash-shared-checkout.md) — parallel sessions share this checkout; use a worktree to test clean HEAD
|
- [No git stash here](no-git-stash-shared-checkout.md) — parallel sessions share this checkout; use a worktree to test clean HEAD
|
||||||
- [Update never kills panes](update-never-kills-panes.md) — updates/restarts keep tmux lane panes; only Quit AgentHub (#65) closes them; KillMode=process
|
- [Update never kills panes](update-never-kills-panes.md) — updates/restarts keep tmux lane panes; only Quit AgentHub (#65) closes them; KillMode=process
|
||||||
- [Claude Code changelog ≤2.1.295](claude-code-changelog-2-1-293.md) — 2.1.285–295 (to 10-08): hooks onFailure:block (keep AgentHub fail-open), OSC 7501 tested (no use), Workflow scriptPath rules
|
- [Claude Code changelog ≤2.1.295](claude-code-changelog-2-1-293.md) — 2.1.285–295 (to 10-08): hooks onFailure:block (keep AgentHub fail-open), OSC 7501 tested (no use), Workflow scriptPath rules
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
---
|
---
|
||||||
name: workspace-rename-step4
|
name: workspace-rename-step4
|
||||||
description: "Removing the old `agenthub lanes`/`--lane` CLI names is deferred to ≥3 releases after the aliases ship; never touch stored data"
|
description: "Rename step 4 DONE 2026-10-26: `agenthub lanes`/`--lane` now exit 1 'renamed'; client/CLI/MCP call /api/workspaces; /api/lanes kept; never touch stored data"
|
||||||
metadata:
|
metadata:
|
||||||
node_type: memory
|
node_type: memory
|
||||||
type: project
|
type: project
|
||||||
originSessionId: 47dc0201-8e49-4fce-9407-f087d3107588
|
originSessionId: 47dc0201-8e49-4fce-9407-f087d3107588
|
||||||
modified: 2026-10-06T04:07:56.796Z
|
modified: 2026-10-26T00:00:00.000Z
|
||||||
---
|
---
|
||||||
|
|
||||||
On 2026-10-06 the user had steps 1–3 of the lane→workspace rename built (aliases `/api/workspaces`, `agenthub workspace`, `--workspace`; help/docs on the new name; a stderr deprecation note for `agenthub lanes`/`--lane`). Step 4 (removing the old CLI names, moving client+CLI requests to `/api/workspaces`) is deferred until the THIRD release after the first one that ships the aliases (the first release after v2.0.1). The plan, checklist and "never touch" list are in `docs/plans/workspace-rename/README.md`.
|
Step 4 of the lane→workspace rename was built on 2026-10-26 (after v2.8.25): `agenthub lanes …` and `--lane` exit 1 with `renamed: use …`; client `api.ts`, `bin/agenthub.js` and `mcp/server.js` request `/api/workspaces`; the `/api/lanes` mount is KEPT (4c default). Plan: `docs/plans/workspace-rename/README.md`.
|
||||||
|
|
||||||
**Why:** the user wants existing installs to update gradually and NOT lose any data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys must never be renamed.
|
**Why:** existing installs update gradually and must not lose data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys are never renamed.
|
||||||
|
|
||||||
**How to apply:** when cutting a release, check whether it is ≥ R+3 (R is recorded in that plan file — fill it in when R is cut) and remind the user that step 4 is due; keep `/api/lanes` unless the user decides otherwise (step 4c, default keep).
|
**How to apply:** the release that ships this needs the EN+VI note from the plan's release checklist ("`agenthub lanes` and `--lane` are removed — use `agenthub workspace` and `--workspace`…"). Do not remove `/api/lanes` unless the user asks (then 410 ERENAMED for one release first).
|
||||||
|
|
||||||
R = v2.1.0 (released 2026-10-06), so step 4 is allowed from the 3rd release after v2.1.0.
|
|
||||||
|
|||||||
@@ -32,5 +32,5 @@ Decisions (user, 2026-10-08):
|
|||||||
- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
|
- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
|
||||||
- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
|
- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
|
||||||
- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
|
- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
|
||||||
- [ ] prod/dev share one DB: restart prod on the new build together with the first run (migration)
|
- [x] prod/dev share one DB: restart prod on the new build together with the first run (migration) — done with the v2.5.x rollout
|
||||||
- [ ] `agenthub stop` in server mode answers EAUTH (fix was blocked; user to decide)
|
- [x] `agenthub stop` in server mode answers EAUTH — resolved: `cmdStop` (`bin/agenthub.js`) turns EAUTH/EADMIN into a clear message naming the admin's Quit and the `systemctl` stop commands; a host shell carries no login, so that is the intended answer
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user